Гарантия строгого нахлёста планов без разрывов в 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> { fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */ /** Все доступные кандидаты начала/окончания плана для указанного КА. */
@@ -61,18 +61,27 @@ class PlanCalculationService(
?.index ?.index
?: -1 ?: -1
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */ /** Старт хвоста при удачном сопоставлении базового плана с текущими окнами. */
val fromStartIndex: Int = if ( val matchedStartIndex: Int = if (previousStartIndex >= 0 && previousEndIndex > previousStartIndex) {
previousStartIndex >= 0 &&
previousEndIndex > previousStartIndex
) {
resolveNextStartIndex( resolveNextStartIndex(
candidates = candidates, candidates = candidates,
currentStartIndex = previousStartIndex, currentStartIndex = previousStartIndex,
endIndex = previousEndIndex endIndex = previousEndIndex
) )
} else { } 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) { if (fromStartIndex !in candidates.indices) {
@@ -197,15 +206,16 @@ class PlanCalculationService(
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
/** /**
* Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ. * Возвращает индекс старта следующего плана так, чтобы он гарантированно стартовал
* строго внутри текущего плана (нахлёст без разрыва).
* *
* Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ, * Сначала пробуем нахлёст на одну ЗРВ: самая поздняя ЗРВ строго раньше конечной ЗРВ
* которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта. * текущего плана. Если такой нет — берём самую позднюю ЗРВ, начинающуюся строго раньше
* Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и * конца текущего плана (нахлёст всё равно сохраняется). Нахлёст считается по времени, а не
* ту же ЗРВ видят несколько станций одновременно. * по плоскому индексу, поэтому устойчив к тому, что одну ЗРВ видят несколько станций сразу.
* *
* Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста), * Если ни одна ЗРВ не начинается внутри текущего плана, продолжить цепочку без разрыва
* чтобы не зациклиться и не повторить тот же самый план. * невозможно — возвращаем `currentStartIndex`, и вызывающий обрывает цепочку.
*/ */
private fun resolveNextStartIndex( private fun resolveNextStartIndex(
candidates: List<StartCandidate>, candidates: List<StartCandidate>,
@@ -214,21 +224,31 @@ class PlanCalculationService(
): Int { ): Int {
/** Время старта текущего плана. */ /** Время старта текущего плана. */
val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime
/** Время конца текущего плана. */
val currentEndTime: LocalDateTime = candidates[endIndex].endTime
/** Время старта конечной ЗРВ текущего плана. */ /** Время старта конечной ЗРВ текущего плана. */
val endStartTime: LocalDateTime = candidates[endIndex].startTime 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 > currentStartTime &&
candidates[index].startTime < endStartTime candidates[index].startTime < endStartTime
} ?: -1 }
if (oneWindowOverlap != null) {
if (overlappingIndex > currentStartIndex) { return oneWindowOverlap
return overlappingIndex
} }
/** Ближайший более поздний старт, если нахлест невозможен. */ /** Запасной нахлёст: последняя ЗРВ, начинающаяся строго раньше конца текущего плана. */
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}") 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>) { private fun assertChainInvariant(plans: List<CalculatedPlan>) {
for (index in 1 until plans.size) { for (index in 1 until plans.size) {
@@ -247,6 +269,10 @@ class PlanCalculationServiceTest {
plans[index].endTime > plans[index - 1].endTime, plans[index].endTime > plans[index - 1].endTime,
"end must strictly increase at index $index: $plans" "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 (outer in plans.indices) {
for (inner in plans.indices) { for (inner in plans.indices) {