diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt index f738466..93f593d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationService.kt @@ -40,10 +40,10 @@ class PlanCalculationService( /** * Строит часть цепочки после уже известного (принятого) плана. * - * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, - * следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось, - * используется безопасный fallback без нахлеста. В обоих случаях конец первого нового - * плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой. + * Первый новый план обязан стартовать строго внутри принятого плана (нахлёст без разрыва) + * и заканчиваться строго позже его конца (без вложенности). Если предыдущий план удалось + * сопоставить с текущими окнами — продолжаем с нахлестом на одну ЗРВ; иначе берём последнюю + * ЗРВ строго внутри принятого плана. Разрыв между принятым планом и хвостом не допускается. */ fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ @@ -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, @@ -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 } /** diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt index 5ffa69d..a6474a0 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlanCalculationServiceTest.kt @@ -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) { 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) {