From 2aafa561edeee5a2ffd1d87dcfd44b07236002f5 Mon Sep 17 00:00:00 2001 From: emelianov Date: Wed, 27 May 2026 10:03:23 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D1=87=D0=B5=D1=82=20=D0=B7=D0=B0=D0=B1?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B5=D0=BA=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ComplexPlanCalculationService.kt | 166 ++++++++++++++---- .../ComplexPlanRunOrchestrationService.kt | 2 +- .../service/CoverageBatchClient.kt | 33 +++- .../CoverageSchemeCalculationClient.kt | 14 ++ .../service/SurveyPlacementService.kt | 18 ++ .../service/SurveyPlacementServiceTest.kt | 33 ++++ 6 files changed, 231 insertions(+), 35 deletions(-) diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt index 4abe92b..61dc722 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanCalculationService.kt @@ -18,8 +18,11 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.OccupiedIntervalDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import java.time.Duration import java.time.LocalDateTime +import kotlin.math.abs import kotlin.system.measureTimeMillis @Service @@ -36,6 +39,11 @@ class ComplexPlanCalculationService( private val logger: Logger = LoggerFactory.getLogger(this::class.java) + companion object { + private const val LOG_INTERVAL_SAMPLE_LIMIT = 10 + private const val ROLL_EPSILON = 0.01 + } + fun process( intervalStart: LocalDateTime, intervalEnd: LocalDateTime, @@ -134,7 +142,7 @@ class ComplexPlanCalculationService( logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId) val plannedModesMs = measureTimeMillis { - addPlannedModes(workingSatellites, intervalStart, intervalEnd) + addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd) } logger.info( "Stage planned-modes-load: runId={}, satellites={}, durationMs={}", @@ -161,8 +169,9 @@ class ComplexPlanCalculationService( } val targetsChunk = buildTargetsChunk(chunk) - var coverageByTargetId = emptyMap>() + var coverageByTargetId = emptyMap>() val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites) + logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals) val coverageFetchMs = measureTimeMillis { coverageByTargetId = coverageCalculationClient.coverageModes( targets = targetsChunk, @@ -189,6 +198,7 @@ class ComplexPlanCalculationService( var acceptedForChunk = 0 val chunkProcessMs = measureTimeMillis { acceptedForChunk = processChunk( + runId = runId, workingSatellites = workingSatellites, cells = chunk, coverageByTargetId = coverageByTargetId, @@ -294,6 +304,7 @@ class ComplexPlanCalculationService( } private fun addPlannedModes( + runId: Long?, workingSatellites: List, intervalStart: LocalDateTime, intervalEnd: LocalDateTime @@ -308,35 +319,35 @@ class ComplexPlanCalculationService( ) workingSatellites.forEach { satellite -> - val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() - plannedModesBySatelliteId[satellite.satelliteId] - .orEmpty() - .forEach { slot -> - surveyPlacementService.mergeSurvey( - existingSurveys = satellite.longMission.surveys, - candidateSurvey = SurveyMode( - revolution = slot.revolution, - time = slot.tn, - timeStop = slot.tk, - roll = slot.roll, - latitude = slot.latitude, - longitude = slot.longitude, - duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(), - contourWKT = slot.contour, - cellNum = -1, - source = SatelliteModeSource.SLOTS, - bookedSlotIds = slot.slotIds, - cellNums = emptyList() - ), - satellite = satellite - ) - } - logger.info( - "Добавлены booked-маршруты для satelliteId={}: count={}", - satellite.satelliteId, - plannedForSatellite.size + val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() + logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite) + plannedForSatellite.forEach { slot -> + surveyPlacementService.mergeSurvey( + existingSurveys = satellite.longMission.surveys, + candidateSurvey = SurveyMode( + revolution = slot.revolution, + time = slot.tn, + timeStop = slot.tk, + roll = slot.roll, + latitude = slot.latitude, + longitude = slot.longitude, + duration = Duration.between(slot.tn, slot.tk).seconds.toDouble(), + contourWKT = slot.contour, + cellNum = -1, + source = SatelliteModeSource.SLOTS, + bookedSlotIds = slot.slotIds, + cellNums = emptyList() + ), + satellite = satellite ) } + logger.info( + "Добавлены booked-маршруты: runId={}, satelliteId={}, count={}", + runId, + satellite.satelliteId, + plannedForSatellite.size + ) + } } private fun buildCoverageTargets(cells: List): List = @@ -367,10 +378,59 @@ class ComplexPlanCalculationService( .sortedWith(compareBy { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) .toList() + private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List) { + if (slots.isEmpty()) { + logger.info("Booked slots loaded: runId={}, satelliteId={}, count=0", runId, satelliteId) + return + } + + logger.info( + "Booked slots loaded: runId={}, satelliteId={}, count={}, interval=[{} - {}], sample={}", + runId, + satelliteId, + slots.size, + slots.minOf { it.tn }, + slots.maxOf { it.tk }, + slots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { slot -> + "start=${slot.tn},end=${slot.tk},roll=${slot.roll},revolution=${slot.revolution},slotIds=${slot.slotIds}" + } + ) + } + + private fun logOccupiedIntervalsSnapshot( + runId: Long?, + chunkNumber: Int, + chunkCount: Int, + occupiedIntervals: List + ) { + val bySource = occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount() + val slotsBySatellite = occupiedIntervals + .filter { it.source == SatelliteModeSource.SLOTS.name } + .groupingBy { it.satelliteId } + .eachCount() + + logger.info( + "Occupied intervals snapshot before coverage request: runId={}, chunk={}/{}, total={}, bySource={}, slotsBySatellite={}, slotsSample={}", + runId, + chunkNumber, + chunkCount, + occupiedIntervals.size, + bySource, + slotsBySatellite, + occupiedIntervals + .filter { it.source == SatelliteModeSource.SLOTS.name } + .take(LOG_INTERVAL_SAMPLE_LIMIT) + .map { interval -> + "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}" + } + ) + } + private fun processChunk( + runId: Long?, workingSatellites: List, cells: List, - coverageByTargetId: Map>, + coverageByTargetId: Map>, intervalStart: LocalDateTime, intervalEnd: LocalDateTime ): Int { @@ -391,6 +451,13 @@ class ComplexPlanCalculationService( val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId } ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван") + logComplanCandidateBookedSlotOverlaps( + runId = runId, + cell = cell, + slot = slot, + existingSurveys = satellite.longMission.surveys + ) + val decision = surveyPlacementService.tryPlaceSurvey( existingSurveys = satellite.longMission.surveys, candidateSurvey = SurveyMode( @@ -425,6 +492,45 @@ class ComplexPlanCalculationService( return acceptedModes } + private fun logComplanCandidateBookedSlotOverlaps( + runId: Long?, + cell: EarthCellWithRequestsDTO, + slot: SlotDTO, + existingSurveys: List + ) { + val overlappingBookedSlots = existingSurveys + .filter { survey -> survey.source == SatelliteModeSource.SLOTS } + .filter { survey -> intersectsInTime(survey.time, survey.timeStop, slot.tn, slot.tk) } + + if (overlappingBookedSlots.isEmpty()) { + return + } + + logger.warn( + "COMPLAN candidate overlaps booked SLOTS before placement: runId={}, satelliteId={}, targetId={}, cellNum={}, candidateInterval=[{} - {}], candidateRoll={}, candidateRevolution={}, overlaps={}, sameRollOverlaps={}, bookedSlotsSample={}", + runId, + slot.satelliteId, + cell.targetId(), + cell.num, + slot.tn, + slot.tk, + slot.roll, + slot.revolution, + overlappingBookedSlots.size, + overlappingBookedSlots.count { survey -> abs(survey.roll - slot.roll) <= ROLL_EPSILON }, + overlappingBookedSlots.take(LOG_INTERVAL_SAMPLE_LIMIT).map { survey -> + "start=${survey.time},end=${survey.timeStop},roll=${survey.roll},source=${survey.source},bookedSlotIds=${survey.bookedSlotIds}" + } + ) + } + + private fun intersectsInTime( + start1: LocalDateTime, + stop1: LocalDateTime, + start2: LocalDateTime, + stop2: LocalDateTime + ): Boolean = start1 < stop2 && start2 < stop1 + private fun hasRemainingCapacity( workingSatellites: List, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt index 32665c6..634b419 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/ComplexPlanRunOrchestrationService.kt @@ -36,7 +36,7 @@ class ComplexPlanRunOrchestrationService( ) lateinit var createdRun: ComplexPlanRunEntity - val createRunMs = measureTimeMillis { + val createRunMs = measureTimeMillis { createdRun = complexPlanRunPersistenceService.createRun( ComplexPlanRunCreateDTO( intervalStart = request.intervalStart, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt index cb741ab..8d34c2c 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageBatchClient.kt @@ -18,6 +18,7 @@ import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageBatchRespon import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO +import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import tools.jackson.databind.ObjectMapper import java.time.LocalDateTime @@ -161,18 +162,42 @@ class CoverageBatchClient(webClientBuilderProvider: ObjectProvider + ): List = + response.map { targetResponse -> + targetResponse.copy( + slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE } + ) + } + private fun requestCoverageModes( targets: List, intervalStart: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt index 7527735..8ae27d1 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/CoverageSchemeCalculationClient.kt @@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider + "satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}" + } + ) + } val result = linkedMapOf>() val durationMs = measureTimeMillis { diff --git a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt index b58ce9f..dcf343d 100644 --- a/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt +++ b/services/pcp-complex-mission-service/src/main/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementService.kt @@ -226,6 +226,9 @@ class SurveyPlacementService { if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) { return@forEach } + if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) { + return@forEach + } val overlapsInTime = intersectsInTime( existing.time, existing.timeStop, @@ -377,6 +380,21 @@ class SurveyPlacementService { return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS } + private fun shouldSkipComplanInsideSlotsMerge( + left: SurveyMode, + right: SurveyMode + ): Boolean { + val complanSurvey = when { + left.source == SatelliteModeSource.COMPLAN && right.source == SatelliteModeSource.SLOTS -> left + right.source == SatelliteModeSource.COMPLAN && left.source == SatelliteModeSource.SLOTS -> right + else -> return false + } + val slotsSurvey = if (complanSurvey === left) right else left + + return !complanSurvey.time.isBefore(slotsSurvey.time) && + !complanSurvey.timeStop.isAfter(slotsSurvey.timeStop) + } + private fun intersectsInTime( start1: LocalDateTime, stop1: LocalDateTime, diff --git a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt index a2dfaa5..51f46bf 100644 --- a/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt +++ b/services/pcp-complex-mission-service/src/test/kotlin/space/nstart/pcp/pcp_satellites_service/service/SurveyPlacementServiceTest.kt @@ -121,6 +121,39 @@ class SurveyPlacementServiceTest { assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) } + @Test + fun `complan inside booked slot is not merged to mixed`() { + val surveys = mutableListOf( + survey( + start = 0, + end = 100, + roll = 10.0, + source = SatelliteModeSource.SLOTS, + contour = "POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))" + ) + ) + val satellite = satellite() + + val decision = service.tryPlaceSurvey( + surveys, + survey( + start = 0, + end = 40, + roll = 10.0, + source = SatelliteModeSource.COMPLAN, + contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))" + ), + satellite + ) + + assertTrue(decision.accepted) + assertEquals(2, surveys.size) + assertTrue(decision.absorbedSurveys.isEmpty()) + assertEquals(1, surveys.count { it.source == SatelliteModeSource.SLOTS }) + assertEquals(1, surveys.count { it.source == SatelliteModeSource.COMPLAN }) + assertFalse(surveys.any { it.source == SatelliteModeSource.MIXED }) + } + @Test fun `different roll conflict resolved by trimming`() { val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0))