diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt index c2a284f..c93c6df 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/repository/PlannedPlanRepository.kt @@ -24,11 +24,11 @@ interface PlannedPlanRepository : JpaRepository { /** Возвращает все планы в порядке старта. */ fun findAllByOrderByStartTime(): List - /** Возвращает будущие planned-планы КА. */ - fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + /** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */ + fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( spacecraftId: String, status: PlannedPlanStatus, - startTime: LocalDateTime + endTime: LocalDateTime ): List /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ 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 d086947..f738466 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 @@ -9,32 +9,41 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import java.time.Duration import java.time.LocalDateTime +/** + * Строит цепочку планов по окнам видимости КА. + * + * Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец + * (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что + * ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий + * план стартует на ЗРВ, попадающей внутрь предыдущего плана. + * + * Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному. + */ @Service class PlanCalculationService( private val planningProperties: PlanningProperties ) { - /** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */ - private val overlapShiftByWindows: Int = 1 - /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ fun calculatePlans(spacecraftPoints: SpacecraftPoints): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ val candidates: List = buildCandidates(spacecraftPoints) - return calculatePlansFromCandidates( + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = 0 + fromStartIndex = 0, + initialLowerEndBound = null ) } /** - * Строит часть цепочки после уже известного плана. + * Строит часть цепочки после уже известного (принятого) плана. * * Если предыдущий план удалось сопоставить с текущими окнами наблюдения, - * следующий план начинается с нахлестом на одну ЗРВ. - * Если сопоставление не удалось, используется безопасный fallback без нахлеста. + * следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось, + * используется безопасный fallback без нахлеста. В обоих случаях конец первого нового + * плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой. */ fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List { /** Все доступные кандидаты начала/окончания плана для указанного КА. */ @@ -53,11 +62,12 @@ class PlanCalculationService( ?: -1 /** Стартовый индекс для пересчёта следующего хвоста цепочки. */ - val initialStartIndex: Int = if ( + val fromStartIndex: Int = if ( previousStartIndex >= 0 && previousEndIndex > previousStartIndex ) { resolveNextStartIndex( + candidates = candidates, currentStartIndex = previousStartIndex, endIndex = previousEndIndex ) @@ -65,14 +75,15 @@ class PlanCalculationService( candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } } - if (initialStartIndex == -1) { + if (fromStartIndex !in candidates.indices) { return emptyList() } - return calculatePlansFromCandidates( + return buildChain( spacecraftId = spacecraftPoints.spacecraftId, candidates = candidates, - initialStartIndex = initialStartIndex + fromStartIndex = fromStartIndex, + initialLowerEndBound = previousPlan.endTime ) } @@ -96,7 +107,8 @@ class PlanCalculationService( /** Индекс лучшего кандидата окончания для ближайшей закладки. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = nearestStartIndex + startIndex = nearestStartIndex, + lowerEndBound = null ) ?: return null return candidates[nearestStartIndex].toCalculatedPlan( @@ -105,37 +117,49 @@ class PlanCalculationService( ) } - /** Преобразует отсортированных кандидатов в последовательную цепочку планов. */ - private fun calculatePlansFromCandidates( + /** + * Строит последовательную цепочку планов, начиная с `fromStartIndex`. + * + * Поддерживает инвариант строгого роста старта и конца: следующий план обязан + * стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один + * план не оказывается внутри другого. + */ + private fun buildChain( spacecraftId: String, candidates: List, - initialStartIndex: Int + fromStartIndex: Int, + initialLowerEndBound: LocalDateTime? ): List { - if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { + if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) { return emptyList() } /** Результирующая цепочка планов. */ val resultPlans: MutableList = mutableListOf() /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ - var currentStartIndex: Int = initialStartIndex + var currentStartIndex: Int = fromStartIndex + /** Конец последнего добавленного плана — нижняя граница для конца следующего. */ + var lowerEndBound: LocalDateTime? = initialLowerEndBound while (currentStartIndex < candidates.lastIndex) { /** Текущий кандидат старта плана. */ val currentCandidate: StartCandidate = candidates[currentStartIndex] - /** Индекс найденного кандидата окончания плана. */ + /** Индекс найденного кандидата окончания плана со строго растущим концом. */ val endIndex: Int = findEndCandidateIndexOrNull( candidates = candidates, - startIndex = currentStartIndex + startIndex = currentStartIndex, + lowerEndBound = lowerEndBound ) ?: break resultPlans += currentCandidate.toCalculatedPlan( spacecraftId = spacecraftId, endTime = candidates[endIndex].endTime ) + lowerEndBound = candidates[endIndex].endTime /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */ val nextStartIndex: Int = resolveNextStartIndex( + candidates = candidates, currentStartIndex = currentStartIndex, endIndex = endIndex ) @@ -162,7 +186,7 @@ class PlanCalculationService( ) } } - .sortedBy { candidate -> candidate.startTime } + .sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime })) /** Возвращает целевую длительность плана из настроек. */ private fun targetDuration(): Duration = @@ -173,31 +197,62 @@ class PlanCalculationService( Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) /** - * Возвращает безопасный индекс старта следующего плана. + * Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ. * - * Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего. - * Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат, + * Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ, + * которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта. + * Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и + * ту же ЗРВ видят несколько станций одновременно. + * + * Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста), * чтобы не зациклиться и не повторить тот же самый план. */ - private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int = - maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows) + private fun resolveNextStartIndex( + candidates: List, + currentStartIndex: Int, + endIndex: Int + ): Int { + /** Время старта текущего плана. */ + val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime + /** Время старта конечной ЗРВ текущего плана. */ + val endStartTime: LocalDateTime = candidates[endIndex].startTime + + /** Самая поздняя ЗРВ строго внутри текущего плана — даёт одно окно нахлеста. */ + val overlappingIndex: Int = candidates.indices.lastOrNull { index -> + candidates[index].startTime > currentStartTime && + candidates[index].startTime < endStartTime + } ?: -1 + + if (overlappingIndex > currentStartIndex) { + return overlappingIndex + } + + /** Ближайший более поздний старт, если нахлест невозможен. */ + return candidates.indexOfFirst { candidate -> candidate.startTime > currentStartTime } + } /** * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`. * - * Сначала сервис старается попасть в целевое окно `target ± delta`. - * Если это невозможно, он всё равно обязан построить план и берёт - * любой доступный конец, наиболее близкий к целевой длительности. + * Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего + * плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности. + * Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно, + * берётся любой допустимый конец, наиболее близкий к целевой длительности. */ private fun findEndCandidateIndexOrNull( candidates: List, - startIndex: Int + startIndex: Int, + lowerEndBound: LocalDateTime? ): Int? { /** Кандидат, с которого начинается рассматриваемый план. */ val startCandidate: StartCandidate = candidates[startIndex] - /** Все доступные кандидаты окончания после выбранного старта. */ + /** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */ val allEndCandidates: List> = candidates.withIndex() - .filter { indexedCandidate -> indexedCandidate.index > startIndex } + .filter { indexedCandidate -> + indexedCandidate.index > startIndex && + indexedCandidate.value.endTime > startCandidate.startTime && + (lowerEndBound == null || indexedCandidate.value.endTime > lowerEndBound) + } if (allEndCandidates.isEmpty()) { return null @@ -239,8 +294,8 @@ class PlanCalculationService( /** * Выбирает лучший конец по минимальному отклонению от целевой длительности. * - * При равной близости предпочтение отдаётся более длинному плану, - * чтобы не занижать результат без необходимости. + * При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу, + * чтобы выбор был детерминированным. */ private fun selectPreferredEndCandidate( candidates: List>, @@ -257,6 +312,8 @@ class PlanCalculationService( ) }.thenByDescending { candidate -> Duration.between(startTime, candidate.value.endTime) + }.thenBy { candidate -> + candidate.value.endTime } ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt index dc6bfa0..b63953e 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCase.kt @@ -87,10 +87,10 @@ class RebuildPlanScheduleUseCase( calculatedBusinessKeys: Set, now: LocalDateTime ) { - plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( + plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime( spacecraftId = spacecraftId, status = PlannedPlanStatus.PLANNED, - startTime = now + endTime = now ) .filter { plan -> plan.businessKey() !in calculatedBusinessKeys } .forEach { plan -> 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 5bbba48..5ffa69d 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 @@ -1,9 +1,11 @@ package space.nstart.pcp_tgu_service.service import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.CalculatedPlan import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.SpacecraftPoints @@ -29,16 +31,16 @@ class PlanCalculationServiceTest { ) ) - assertEquals(4, plans.size) + assertChainInvariant(plans) + assertEquals(3, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) assertEquals(start.plusHours(16), plans[1].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plans[1].endTime) assertEquals("KPP-3", plans[1].kppId) assertEquals(start.plusHours(32), plans[2].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime) - assertEquals(start.plusHours(40), plans[3].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime) + assertEquals("KPP-6", plans[2].kppId) } @Test @@ -59,14 +61,15 @@ class PlanCalculationServiceTest { val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first()) - assertEquals(3, plansAfterFirst.size) + assertChainInvariant(plansAfterFirst) + assertEquals(2, plansAfterFirst.size) assertEquals(start.plusHours(16), plansAfterFirst[0].startTime) - assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime) + assertEquals(start.plusHours(40).plusMinutes(10), plansAfterFirst[0].endTime) assertEquals("KPP-3", plansAfterFirst[0].kppId) assertEquals(start.plusHours(32), plansAfterFirst[1].startTime) assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime) - assertEquals(start.plusHours(40), plansAfterFirst[2].startTime) - assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime) + // Хвост не должен заканчиваться раньше принятого базового плана. + assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime }) } @Test @@ -96,6 +99,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) @@ -135,6 +139,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusMinutes(55), plans[1].startTime) @@ -153,6 +158,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(2, plans.size) assertEquals(start, plans[0].startTime) assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime) @@ -174,6 +180,7 @@ class PlanCalculationServiceTest { ) ) + assertChainInvariant(plans) assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime) } @@ -192,12 +199,65 @@ class PlanCalculationServiceTest { val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray())) + assertChainInvariant(plans) assertTrue(plans.isNotEmpty()) assertTrue(plans.size <= simultaneousWindows.size) assertEquals(start, plans.first().startTime) assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime) } + @Test + fun `calculatePlans never nests plans when several stations see the same passes (Emissio case)`() { + // Прод-конфигурация: цель 3 часа, допуск 1 час. + val service = createService(targetMinutes = 180L, deltaMinutes = 60L) + val start = LocalDateTime.of(2026, 3, 10, 10, 0) + // Пролёты примерно раз в час, каждый виден тремя станциями почти одновременно + // (это и есть корень вложенности у Emissio): одинаковые/близкие старты, дублирующиеся ЗРВ. + val stations = listOf("ST-A", "ST-B", "ST-C") + val windows = buildList { + for (passIndex in 0..8) { + stations.forEachIndexed { stationOffset, stationId -> + add( + window( + pointId = "$stationId-$passIndex", + startTime = start.plusHours(passIndex.toLong()).plusMinutes(stationOffset.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) { + assertTrue( + plans[index].startTime > plans[index - 1].startTime, + "start must strictly increase at index $index: $plans" + ) + assertTrue( + plans[index].endTime > plans[index - 1].endTime, + "end must strictly increase at index $index: $plans" + ) + } + for (outer in plans.indices) { + for (inner in plans.indices) { + if (outer == inner) continue + val contains = plans[outer].startTime <= plans[inner].startTime && + plans[inner].endTime <= plans[outer].endTime + assertFalse(contains, "plan $inner must not be nested in plan $outer: $plans") + } + } + } + private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService = PlanCalculationService( PlanningProperties( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt index 3b3beea..11ebc40 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/RebuildPlanScheduleUseCaseTest.kt @@ -27,6 +27,17 @@ class RebuildPlanScheduleUseCaseTest { fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { val spacecraftId = "25544" val planStore = linkedMapOf() + // Устаревший PLANNED-план, который уже стартовал, но ещё не завершился — + // должен быть погашен по новому условию end_time > now (а не start_time > now). + val stalePlanId = UUID.randomUUID() + planStore[stalePlanId] = PlannedPlanEntity( + planId = stalePlanId, + spacecraftId = spacecraftId, + startTime = LocalDateTime.now().minusHours(1), + endTime = LocalDateTime.now().plusHours(1), + kppId = "KPP-STALE", + status = PlannedPlanStatus.PLANNED + ) val plannedRepository = plannedRepository(planStore) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) val stateRepository = stateRepository(state) @@ -58,6 +69,8 @@ class RebuildPlanScheduleUseCaseTest { service.rebuild(spacecraftId) val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId + // Устаревший активный план без места в новой цепочке погашен уже на первом rebuild. + assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.getValue(stalePlanId).status) `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) service.rebuild(spacecraftId) @@ -140,13 +153,13 @@ class RebuildPlanScheduleUseCaseTest { store[plan.planId] = plan plan } - `when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) + `when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) .thenAnswer { invocation -> store.values .filter { plan -> plan.spacecraftId == invocation.arguments[0] && plan.status == invocation.arguments[1] && - plan.startTime > invocation.arguments[2] as LocalDateTime + plan.endTime > invocation.arguments[2] as LocalDateTime } .sortedBy { plan -> plan.startTime } }