Запрет вложенных планов: строго монотонная цепочка в pcp-tgu-service
Цепочка планов теперь строит соседние планы со строго возрастающими стартом и концом, поэтому ни один план не может лежать целиком внутри другого. Нахлёст «на одну ЗРВ» считается по времени, а не по плоскому индексу кандидата — это убирает вложенность при нескольких станциях, видящих один пролёт одновременно (кейс Emissio). Подчистка устаревших PLANNED-планов при rebuild переведена на условие end_time > now, чтобы гасить и уже стартовавшие вложенные планы.
This commit is contained in:
+3
-3
@@ -24,11 +24,11 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
|
|||||||
/** Возвращает все планы в порядке старта. */
|
/** Возвращает все планы в порядке старта. */
|
||||||
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
|
||||||
|
|
||||||
/** Возвращает будущие planned-планы КА. */
|
/** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */
|
||||||
fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
|
fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(
|
||||||
spacecraftId: String,
|
spacecraftId: String,
|
||||||
status: PlannedPlanStatus,
|
status: PlannedPlanStatus,
|
||||||
startTime: LocalDateTime
|
endTime: LocalDateTime
|
||||||
): List<PlannedPlanEntity>
|
): List<PlannedPlanEntity>
|
||||||
|
|
||||||
/** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */
|
/** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */
|
||||||
|
|||||||
+91
-34
@@ -9,32 +9,41 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
|
|||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит цепочку планов по окнам видимости КА.
|
||||||
|
*
|
||||||
|
* Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец
|
||||||
|
* (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что
|
||||||
|
* ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий
|
||||||
|
* план стартует на ЗРВ, попадающей внутрь предыдущего плана.
|
||||||
|
*
|
||||||
|
* Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному.
|
||||||
|
*/
|
||||||
@Service
|
@Service
|
||||||
class PlanCalculationService(
|
class PlanCalculationService(
|
||||||
private val planningProperties: PlanningProperties
|
private val planningProperties: PlanningProperties
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */
|
|
||||||
private val overlapShiftByWindows: Int = 1
|
|
||||||
|
|
||||||
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
|
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
|
||||||
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
|
||||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||||
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
|
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
|
||||||
|
|
||||||
return calculatePlansFromCandidates(
|
return buildChain(
|
||||||
spacecraftId = spacecraftPoints.spacecraftId,
|
spacecraftId = spacecraftPoints.spacecraftId,
|
||||||
candidates = candidates,
|
candidates = candidates,
|
||||||
initialStartIndex = 0
|
fromStartIndex = 0,
|
||||||
|
initialLowerEndBound = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Строит часть цепочки после уже известного плана.
|
* Строит часть цепочки после уже известного (принятого) плана.
|
||||||
*
|
*
|
||||||
* Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
|
* Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
|
||||||
* следующий план начинается с нахлестом на одну ЗРВ.
|
* следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось,
|
||||||
* Если сопоставление не удалось, используется безопасный fallback без нахлеста.
|
* используется безопасный fallback без нахлеста. В обоих случаях конец первого нового
|
||||||
|
* плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой.
|
||||||
*/
|
*/
|
||||||
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
|
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
|
||||||
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
/** Все доступные кандидаты начала/окончания плана для указанного КА. */
|
||||||
@@ -53,11 +62,12 @@ class PlanCalculationService(
|
|||||||
?: -1
|
?: -1
|
||||||
|
|
||||||
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */
|
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */
|
||||||
val initialStartIndex: Int = if (
|
val fromStartIndex: Int = if (
|
||||||
previousStartIndex >= 0 &&
|
previousStartIndex >= 0 &&
|
||||||
previousEndIndex > previousStartIndex
|
previousEndIndex > previousStartIndex
|
||||||
) {
|
) {
|
||||||
resolveNextStartIndex(
|
resolveNextStartIndex(
|
||||||
|
candidates = candidates,
|
||||||
currentStartIndex = previousStartIndex,
|
currentStartIndex = previousStartIndex,
|
||||||
endIndex = previousEndIndex
|
endIndex = previousEndIndex
|
||||||
)
|
)
|
||||||
@@ -65,14 +75,15 @@ class PlanCalculationService(
|
|||||||
candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
|
candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initialStartIndex == -1) {
|
if (fromStartIndex !in candidates.indices) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculatePlansFromCandidates(
|
return buildChain(
|
||||||
spacecraftId = spacecraftPoints.spacecraftId,
|
spacecraftId = spacecraftPoints.spacecraftId,
|
||||||
candidates = candidates,
|
candidates = candidates,
|
||||||
initialStartIndex = initialStartIndex
|
fromStartIndex = fromStartIndex,
|
||||||
|
initialLowerEndBound = previousPlan.endTime
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +107,8 @@ class PlanCalculationService(
|
|||||||
/** Индекс лучшего кандидата окончания для ближайшей закладки. */
|
/** Индекс лучшего кандидата окончания для ближайшей закладки. */
|
||||||
val endIndex: Int = findEndCandidateIndexOrNull(
|
val endIndex: Int = findEndCandidateIndexOrNull(
|
||||||
candidates = candidates,
|
candidates = candidates,
|
||||||
startIndex = nearestStartIndex
|
startIndex = nearestStartIndex,
|
||||||
|
lowerEndBound = null
|
||||||
) ?: return null
|
) ?: return null
|
||||||
|
|
||||||
return candidates[nearestStartIndex].toCalculatedPlan(
|
return candidates[nearestStartIndex].toCalculatedPlan(
|
||||||
@@ -105,37 +117,49 @@ class PlanCalculationService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Преобразует отсортированных кандидатов в последовательную цепочку планов. */
|
/**
|
||||||
private fun calculatePlansFromCandidates(
|
* Строит последовательную цепочку планов, начиная с `fromStartIndex`.
|
||||||
|
*
|
||||||
|
* Поддерживает инвариант строгого роста старта и конца: следующий план обязан
|
||||||
|
* стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один
|
||||||
|
* план не оказывается внутри другого.
|
||||||
|
*/
|
||||||
|
private fun buildChain(
|
||||||
spacecraftId: String,
|
spacecraftId: String,
|
||||||
candidates: List<StartCandidate>,
|
candidates: List<StartCandidate>,
|
||||||
initialStartIndex: Int
|
fromStartIndex: Int,
|
||||||
|
initialLowerEndBound: LocalDateTime?
|
||||||
): List<CalculatedPlan> {
|
): List<CalculatedPlan> {
|
||||||
if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) {
|
if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Результирующая цепочка планов. */
|
/** Результирующая цепочка планов. */
|
||||||
val resultPlans: MutableList<CalculatedPlan> = mutableListOf()
|
val resultPlans: MutableList<CalculatedPlan> = mutableListOf()
|
||||||
/** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */
|
/** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */
|
||||||
var currentStartIndex: Int = initialStartIndex
|
var currentStartIndex: Int = fromStartIndex
|
||||||
|
/** Конец последнего добавленного плана — нижняя граница для конца следующего. */
|
||||||
|
var lowerEndBound: LocalDateTime? = initialLowerEndBound
|
||||||
|
|
||||||
while (currentStartIndex < candidates.lastIndex) {
|
while (currentStartIndex < candidates.lastIndex) {
|
||||||
/** Текущий кандидат старта плана. */
|
/** Текущий кандидат старта плана. */
|
||||||
val currentCandidate: StartCandidate = candidates[currentStartIndex]
|
val currentCandidate: StartCandidate = candidates[currentStartIndex]
|
||||||
/** Индекс найденного кандидата окончания плана. */
|
/** Индекс найденного кандидата окончания плана со строго растущим концом. */
|
||||||
val endIndex: Int = findEndCandidateIndexOrNull(
|
val endIndex: Int = findEndCandidateIndexOrNull(
|
||||||
candidates = candidates,
|
candidates = candidates,
|
||||||
startIndex = currentStartIndex
|
startIndex = currentStartIndex,
|
||||||
|
lowerEndBound = lowerEndBound
|
||||||
) ?: break
|
) ?: break
|
||||||
|
|
||||||
resultPlans += currentCandidate.toCalculatedPlan(
|
resultPlans += currentCandidate.toCalculatedPlan(
|
||||||
spacecraftId = spacecraftId,
|
spacecraftId = spacecraftId,
|
||||||
endTime = candidates[endIndex].endTime
|
endTime = candidates[endIndex].endTime
|
||||||
)
|
)
|
||||||
|
lowerEndBound = candidates[endIndex].endTime
|
||||||
|
|
||||||
/** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
|
/** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
|
||||||
val nextStartIndex: Int = resolveNextStartIndex(
|
val nextStartIndex: Int = resolveNextStartIndex(
|
||||||
|
candidates = candidates,
|
||||||
currentStartIndex = currentStartIndex,
|
currentStartIndex = currentStartIndex,
|
||||||
endIndex = endIndex
|
endIndex = endIndex
|
||||||
)
|
)
|
||||||
@@ -162,7 +186,7 @@ class PlanCalculationService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sortedBy { candidate -> candidate.startTime }
|
.sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime }))
|
||||||
|
|
||||||
/** Возвращает целевую длительность плана из настроек. */
|
/** Возвращает целевую длительность плана из настроек. */
|
||||||
private fun targetDuration(): Duration =
|
private fun targetDuration(): Duration =
|
||||||
@@ -173,31 +197,62 @@ class PlanCalculationService(
|
|||||||
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
|
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Возвращает безопасный индекс старта следующего плана.
|
* Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ.
|
||||||
*
|
*
|
||||||
* Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего.
|
* Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ,
|
||||||
* Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат,
|
* которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта.
|
||||||
|
* Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и
|
||||||
|
* ту же ЗРВ видят несколько станций одновременно.
|
||||||
|
*
|
||||||
|
* Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста),
|
||||||
* чтобы не зациклиться и не повторить тот же самый план.
|
* чтобы не зациклиться и не повторить тот же самый план.
|
||||||
*/
|
*/
|
||||||
private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int =
|
private fun resolveNextStartIndex(
|
||||||
maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows)
|
candidates: List<StartCandidate>,
|
||||||
|
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`.
|
* Находит лучший конечный кандидат для плана, начинающегося с `startIndex`.
|
||||||
*
|
*
|
||||||
* Сначала сервис старается попасть в целевое окно `target ± delta`.
|
* Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего
|
||||||
* Если это невозможно, он всё равно обязан построить план и берёт
|
* плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности.
|
||||||
* любой доступный конец, наиболее близкий к целевой длительности.
|
* Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно,
|
||||||
|
* берётся любой допустимый конец, наиболее близкий к целевой длительности.
|
||||||
*/
|
*/
|
||||||
private fun findEndCandidateIndexOrNull(
|
private fun findEndCandidateIndexOrNull(
|
||||||
candidates: List<StartCandidate>,
|
candidates: List<StartCandidate>,
|
||||||
startIndex: Int
|
startIndex: Int,
|
||||||
|
lowerEndBound: LocalDateTime?
|
||||||
): Int? {
|
): Int? {
|
||||||
/** Кандидат, с которого начинается рассматриваемый план. */
|
/** Кандидат, с которого начинается рассматриваемый план. */
|
||||||
val startCandidate: StartCandidate = candidates[startIndex]
|
val startCandidate: StartCandidate = candidates[startIndex]
|
||||||
/** Все доступные кандидаты окончания после выбранного старта. */
|
/** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */
|
||||||
val allEndCandidates: List<IndexedValue<StartCandidate>> = candidates.withIndex()
|
val allEndCandidates: List<IndexedValue<StartCandidate>> = 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()) {
|
if (allEndCandidates.isEmpty()) {
|
||||||
return null
|
return null
|
||||||
@@ -239,8 +294,8 @@ class PlanCalculationService(
|
|||||||
/**
|
/**
|
||||||
* Выбирает лучший конец по минимальному отклонению от целевой длительности.
|
* Выбирает лучший конец по минимальному отклонению от целевой длительности.
|
||||||
*
|
*
|
||||||
* При равной близости предпочтение отдаётся более длинному плану,
|
* При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу,
|
||||||
* чтобы не занижать результат без необходимости.
|
* чтобы выбор был детерминированным.
|
||||||
*/
|
*/
|
||||||
private fun selectPreferredEndCandidate(
|
private fun selectPreferredEndCandidate(
|
||||||
candidates: List<IndexedValue<StartCandidate>>,
|
candidates: List<IndexedValue<StartCandidate>>,
|
||||||
@@ -257,6 +312,8 @@ class PlanCalculationService(
|
|||||||
)
|
)
|
||||||
}.thenByDescending { candidate ->
|
}.thenByDescending { candidate ->
|
||||||
Duration.between(startTime, candidate.value.endTime)
|
Duration.between(startTime, candidate.value.endTime)
|
||||||
|
}.thenBy { candidate ->
|
||||||
|
candidate.value.endTime
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -87,10 +87,10 @@ class RebuildPlanScheduleUseCase(
|
|||||||
calculatedBusinessKeys: Set<String>,
|
calculatedBusinessKeys: Set<String>,
|
||||||
now: LocalDateTime
|
now: LocalDateTime
|
||||||
) {
|
) {
|
||||||
plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(
|
plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(
|
||||||
spacecraftId = spacecraftId,
|
spacecraftId = spacecraftId,
|
||||||
status = PlannedPlanStatus.PLANNED,
|
status = PlannedPlanStatus.PLANNED,
|
||||||
startTime = now
|
endTime = now
|
||||||
)
|
)
|
||||||
.filter { plan -> plan.businessKey() !in calculatedBusinessKeys }
|
.filter { plan -> plan.businessKey() !in calculatedBusinessKeys }
|
||||||
.forEach { plan ->
|
.forEach { plan ->
|
||||||
|
|||||||
+68
-8
@@ -1,9 +1,11 @@
|
|||||||
package space.nstart.pcp_tgu_service.service
|
package space.nstart.pcp_tgu_service.service
|
||||||
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import space.nstart.pcp_tgu_service.config.PlanningProperties
|
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.InsertionPoint
|
||||||
import space.nstart.pcp_tgu_service.domain.ObservationWindow
|
import space.nstart.pcp_tgu_service.domain.ObservationWindow
|
||||||
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
|
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, plans[0].startTime)
|
||||||
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
|
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
|
||||||
assertEquals(start.plusHours(16), plans[1].startTime)
|
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("KPP-3", plans[1].kppId)
|
||||||
assertEquals(start.plusHours(32), plans[2].startTime)
|
assertEquals(start.plusHours(32), plans[2].startTime)
|
||||||
assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime)
|
assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime)
|
||||||
assertEquals(start.plusHours(40), plans[3].startTime)
|
assertEquals("KPP-6", plans[2].kppId)
|
||||||
assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -59,14 +61,15 @@ class PlanCalculationServiceTest {
|
|||||||
|
|
||||||
val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first())
|
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.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("KPP-3", plansAfterFirst[0].kppId)
|
||||||
assertEquals(start.plusHours(32), plansAfterFirst[1].startTime)
|
assertEquals(start.plusHours(32), plansAfterFirst[1].startTime)
|
||||||
assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime)
|
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
|
@Test
|
||||||
@@ -96,6 +99,7 @@ class PlanCalculationServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assertChainInvariant(plans)
|
||||||
assertEquals(2, plans.size)
|
assertEquals(2, plans.size)
|
||||||
assertEquals(start, plans[0].startTime)
|
assertEquals(start, plans[0].startTime)
|
||||||
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
|
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
|
||||||
@@ -135,6 +139,7 @@ class PlanCalculationServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assertChainInvariant(plans)
|
||||||
assertEquals(2, plans.size)
|
assertEquals(2, plans.size)
|
||||||
assertEquals(start, plans[0].startTime)
|
assertEquals(start, plans[0].startTime)
|
||||||
assertEquals(start.plusMinutes(55), plans[1].startTime)
|
assertEquals(start.plusMinutes(55), plans[1].startTime)
|
||||||
@@ -153,6 +158,7 @@ class PlanCalculationServiceTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assertChainInvariant(plans)
|
||||||
assertEquals(2, plans.size)
|
assertEquals(2, plans.size)
|
||||||
assertEquals(start, plans[0].startTime)
|
assertEquals(start, plans[0].startTime)
|
||||||
assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime)
|
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)
|
assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,12 +199,65 @@ class PlanCalculationServiceTest {
|
|||||||
|
|
||||||
val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray()))
|
val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray()))
|
||||||
|
|
||||||
|
assertChainInvariant(plans)
|
||||||
assertTrue(plans.isNotEmpty())
|
assertTrue(plans.isNotEmpty())
|
||||||
assertTrue(plans.size <= simultaneousWindows.size)
|
assertTrue(plans.size <= simultaneousWindows.size)
|
||||||
assertEquals(start, plans.first().startTime)
|
assertEquals(start, plans.first().startTime)
|
||||||
assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime)
|
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<CalculatedPlan>) {
|
||||||
|
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 =
|
private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService =
|
||||||
PlanCalculationService(
|
PlanCalculationService(
|
||||||
PlanningProperties(
|
PlanningProperties(
|
||||||
|
|||||||
+15
-2
@@ -27,6 +27,17 @@ class RebuildPlanScheduleUseCaseTest {
|
|||||||
fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() {
|
fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() {
|
||||||
val spacecraftId = "25544"
|
val spacecraftId = "25544"
|
||||||
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
|
||||||
|
// Устаревший 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 plannedRepository = plannedRepository(planStore)
|
||||||
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
|
||||||
val stateRepository = stateRepository(state)
|
val stateRepository = stateRepository(state)
|
||||||
@@ -58,6 +69,8 @@ class RebuildPlanScheduleUseCaseTest {
|
|||||||
|
|
||||||
service.rebuild(spacecraftId)
|
service.rebuild(spacecraftId)
|
||||||
val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId
|
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))
|
`when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan))
|
||||||
service.rebuild(spacecraftId)
|
service.rebuild(spacecraftId)
|
||||||
@@ -140,13 +153,13 @@ class RebuildPlanScheduleUseCaseTest {
|
|||||||
store[plan.planId] = plan
|
store[plan.planId] = plan
|
||||||
plan
|
plan
|
||||||
}
|
}
|
||||||
`when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull()))
|
`when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull()))
|
||||||
.thenAnswer { invocation ->
|
.thenAnswer { invocation ->
|
||||||
store.values
|
store.values
|
||||||
.filter { plan ->
|
.filter { plan ->
|
||||||
plan.spacecraftId == invocation.arguments[0] &&
|
plan.spacecraftId == invocation.arguments[0] &&
|
||||||
plan.status == invocation.arguments[1] &&
|
plan.status == invocation.arguments[1] &&
|
||||||
plan.startTime > invocation.arguments[2] as LocalDateTime
|
plan.endTime > invocation.arguments[2] as LocalDateTime
|
||||||
}
|
}
|
||||||
.sortedBy { plan -> plan.startTime }
|
.sortedBy { plan -> plan.startTime }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user