Гарантия строгого нахлёста планов без разрывов в pcp-tgu-service

Старт следующего плана всегда выбирается строго внутри текущего плана
(нахлёст по времени), а раньше был fallback на ближайший более поздний
старт, который мог дать разрыв. Если ни одна ЗРВ не начинается внутри
текущего плана — цепочка обрывается, но разрыв между планами не создаётся.
calculatePlansAfter тоже стартует хвост строго внутри принятого плана.

Инвариант цепочки в тестах усилен проверкой строгого нахлёста; добавлен
тест на провал видимости.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 11:18:02 +03:00
parent bd59204fcf
commit e8f253ac6a
2 changed files with 73 additions and 27 deletions
@@ -40,10 +40,10 @@ class PlanCalculationService(
/**
* Строит часть цепочки после уже известного (принятого) плана.
*
* Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
* следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось,
* используется безопасный fallback без нахлеста. В обоих случаях конец первого нового
* плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой.
* Первый новый план обязан стартовать строго внутри принятого плана (нахлёст без разрыва)
* и заканчиваться строго позже его конца (без вложенности). Если предыдущий план удалось
* сопоставить с текущими окнами — продолжаем с нахлестом на одну ЗРВ; иначе берём последнюю
* ЗРВ строго внутри принятого плана. Разрыв между принятым планом и хвостом не допускается.
*/
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
@@ -61,18 +61,27 @@ class PlanCalculationService(
?.index
?: -1
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */
val fromStartIndex: Int = if (
previousStartIndex >= 0 &&
previousEndIndex > previousStartIndex
) {
/** Старт хвоста при удачном сопоставлении базового плана с текущими окнами. */
val matchedStartIndex: Int = if (previousStartIndex >= 0 && previousEndIndex > previousStartIndex) {
resolveNextStartIndex(
candidates = candidates,
currentStartIndex = previousStartIndex,
endIndex = previousEndIndex
)
} else {
candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
previousStartIndex
}
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */
val fromStartIndex: Int = if (matchedStartIndex > previousStartIndex) {
matchedStartIndex
} else {
// База не сопоставилась или нахлёст по её окнам невозможен: стартуем с последней ЗРВ
// строго внутри принятого плана — нахлёст с базой без разрыва и без вложенности.
candidates.indices.lastOrNull { index ->
candidates[index].startTime > previousPlan.startTime &&
candidates[index].startTime < previousPlan.endTime
} ?: -1
}
if (fromStartIndex !in candidates.indices) {
@@ -197,15 +206,16 @@ class PlanCalculationService(
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
/**
* Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ.
* Возвращает индекс старта следующего плана так, чтобы он гарантированно стартовал
* строго внутри текущего плана (нахлёст без разрыва).
*
* Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ,
* которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта.
* Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и
* ту же ЗРВ видят несколько станций одновременно.
* Сначала пробуем нахлёст на одну ЗРВ: самая поздняя ЗРВ строго раньше конечной ЗРВ
* текущего плана. Если такой нет — берём самую позднюю ЗРВ, начинающуюся строго раньше
* конца текущего плана (нахлёст всё равно сохраняется). Нахлёст считается по времени, а не
* по плоскому индексу, поэтому устойчив к тому, что одну ЗРВ видят несколько станций сразу.
*
* Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста),
* чтобы не зациклиться и не повторить тот же самый план.
* Если ни одна ЗРВ не начинается внутри текущего плана, продолжить цепочку без разрыва
* невозможно — возвращаем `currentStartIndex`, и вызывающий обрывает цепочку.
*/
private fun resolveNextStartIndex(
candidates: List<StartCandidate>,
@@ -214,21 +224,31 @@ class PlanCalculationService(
): Int {
/** Время старта текущего плана. */
val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime
/** Время конца текущего плана. */
val currentEndTime: LocalDateTime = candidates[endIndex].endTime
/** Время старта конечной ЗРВ текущего плана. */
val endStartTime: LocalDateTime = candidates[endIndex].startTime
/** Самая поздняя ЗРВ строго внутри текущего плана — даёт одно окно нахлеста. */
val overlappingIndex: Int = candidates.indices.lastOrNull { index ->
/** Предпочтительный нахлёст: последняя ЗРВ строго перед конечной ЗРВ. */
val oneWindowOverlap: Int? = candidates.indices.lastOrNull { index ->
candidates[index].startTime > currentStartTime &&
candidates[index].startTime < endStartTime
} ?: -1
if (overlappingIndex > currentStartIndex) {
return overlappingIndex
}
if (oneWindowOverlap != null) {
return oneWindowOverlap
}
/** Ближайший более поздний старт, если нахлест невозможен. */
return candidates.indexOfFirst { candidate -> candidate.startTime > currentStartTime }
/** Запасной нахлёст: последняя ЗРВ, начинающаяся строго раньше конца текущего плана. */
val anyOverlap: Int? = candidates.indices.lastOrNull { index ->
candidates[index].startTime > currentStartTime &&
candidates[index].startTime < currentEndTime
}
if (anyOverlap != null) {
return anyOverlap
}
// Нахлёст невозможен — обрываем цепочку, чтобы не было разрыва между планами.
return currentStartIndex
}
/**
@@ -233,9 +233,31 @@ class PlanCalculationServiceTest {
assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}")
}
@Test
fun `calculatePlans never leaves a gap between plans across a visibility gap`() {
val service = createService(targetMinutes = 180L, deltaMinutes = 60L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
// Плотный кластер окон, затем большой провал видимости, затем далёкий кластер.
// Провал допустимо перекрыть одним длинным планом, но разрыва между планами быть не должно.
val windows = buildList {
for (minutes in 0..240 step 20) {
add(window("NEAR-$minutes", start.plusMinutes(minutes.toLong())))
}
for (minutes in 0..120 step 20) {
add(window("FAR-$minutes", start.plusDays(3).plusMinutes(minutes.toLong())))
}
}
val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray()))
// Главное требование: каждый следующий план стартует строго внутри предыдущего (без разрыва).
assertChainInvariant(plans)
assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}")
}
/**
* Проверяет главный инвариант цепочки: старт и конец строго возрастают, и ни один план
* не лежит целиком внутри другого.
* Проверяет главный инвариант цепочки: старт и конец строго возрастают, соседние планы
* строго перекрываются (без разрыва), и ни один план не лежит целиком внутри другого.
*/
private fun assertChainInvariant(plans: List<CalculatedPlan>) {
for (index in 1 until plans.size) {
@@ -247,6 +269,10 @@ class PlanCalculationServiceTest {
plans[index].endTime > plans[index - 1].endTime,
"end must strictly increase at index $index: $plans"
)
assertTrue(
plans[index].startTime < plans[index - 1].endTime,
"plans must strictly overlap (no gap) at index $index: $plans"
)
}
for (outer in plans.indices) {
for (inner in plans.indices) {