Гарантия строгого нахлёста планов без разрывов в pcp-tgu-service
Старт следующего плана всегда выбирается строго внутри текущего плана (нахлёст по времени), а раньше был fallback на ближайший более поздний старт, который мог дать разрыв. Если ни одна ЗРВ не начинается внутри текущего плана — цепочка обрывается, но разрыв между планами не создаётся. calculatePlansAfter тоже стартует хвост строго внутри принятого плана. Инвариант цепочки в тестах усилен проверкой строгого нахлёста; добавлен тест на провал видимости.
This commit is contained in:
+45
-25
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+28
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user