Гарантия строгого нахлёста планов без разрывов в 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> {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-2
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user