Учет забронированных слотов при расчете комплексного плана

This commit is contained in:
emelianov
2026-05-27 10:03:23 +03:00
parent 4d2a5952c9
commit 2aafa561ed
6 changed files with 231 additions and 35 deletions
@@ -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.OccupiedIntervalDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageStrategy 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.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.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import kotlin.math.abs
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@Service @Service
@@ -36,6 +39,11 @@ class ComplexPlanCalculationService(
private val logger: Logger = LoggerFactory.getLogger(this::class.java) 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( fun process(
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime, intervalEnd: LocalDateTime,
@@ -134,7 +142,7 @@ class ComplexPlanCalculationService(
logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId) logger.info("Шаг batch: runId={}, загрузка booked-маршрутов из slots-service", runId)
val plannedModesMs = measureTimeMillis { val plannedModesMs = measureTimeMillis {
addPlannedModes(workingSatellites, intervalStart, intervalEnd) addPlannedModes(runId, workingSatellites, intervalStart, intervalEnd)
} }
logger.info( logger.info(
"Stage planned-modes-load: runId={}, satellites={}, durationMs={}", "Stage planned-modes-load: runId={}, satellites={}, durationMs={}",
@@ -161,8 +169,9 @@ class ComplexPlanCalculationService(
} }
val targetsChunk = buildTargetsChunk(chunk) val targetsChunk = buildTargetsChunk(chunk)
var coverageByTargetId = emptyMap<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>() var coverageByTargetId = emptyMap<Long, List<SlotDTO>>()
val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites) val occupiedIntervals = buildOccupiedIntervalsSnapshot(workingSatellites)
logOccupiedIntervalsSnapshot(runId, chunkIndex + 1, chunkCount, occupiedIntervals)
val coverageFetchMs = measureTimeMillis { val coverageFetchMs = measureTimeMillis {
coverageByTargetId = coverageCalculationClient.coverageModes( coverageByTargetId = coverageCalculationClient.coverageModes(
targets = targetsChunk, targets = targetsChunk,
@@ -189,6 +198,7 @@ class ComplexPlanCalculationService(
var acceptedForChunk = 0 var acceptedForChunk = 0
val chunkProcessMs = measureTimeMillis { val chunkProcessMs = measureTimeMillis {
acceptedForChunk = processChunk( acceptedForChunk = processChunk(
runId = runId,
workingSatellites = workingSatellites, workingSatellites = workingSatellites,
cells = chunk, cells = chunk,
coverageByTargetId = coverageByTargetId, coverageByTargetId = coverageByTargetId,
@@ -294,6 +304,7 @@ class ComplexPlanCalculationService(
} }
private fun addPlannedModes( private fun addPlannedModes(
runId: Long?,
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime intervalEnd: LocalDateTime
@@ -309,9 +320,8 @@ class ComplexPlanCalculationService(
workingSatellites.forEach { satellite -> workingSatellites.forEach { satellite ->
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty() val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
plannedModesBySatelliteId[satellite.satelliteId] logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
.orEmpty() plannedForSatellite.forEach { slot ->
.forEach { slot ->
surveyPlacementService.mergeSurvey( surveyPlacementService.mergeSurvey(
existingSurveys = satellite.longMission.surveys, existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode( candidateSurvey = SurveyMode(
@@ -332,7 +342,8 @@ class ComplexPlanCalculationService(
) )
} }
logger.info( logger.info(
"Добавлены booked-маршруты для satelliteId={}: count={}", "Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
runId,
satellite.satelliteId, satellite.satelliteId,
plannedForSatellite.size plannedForSatellite.size
) )
@@ -367,10 +378,59 @@ class ComplexPlanCalculationService(
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime }) .sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
.toList() .toList()
private fun logBookedSlotsLoaded(runId: Long?, satelliteId: Long, slots: List<SurveySlotDTO>) {
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<OccupiedIntervalDTO>
) {
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( private fun processChunk(
runId: Long?,
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
cells: List<EarthCellWithRequestsDTO>, cells: List<EarthCellWithRequestsDTO>,
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>, coverageByTargetId: Map<Long, List<SlotDTO>>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
intervalEnd: LocalDateTime intervalEnd: LocalDateTime
): Int { ): Int {
@@ -391,6 +451,13 @@ class ComplexPlanCalculationService(
val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId } val satellite = workingSatellites.find { it.satelliteId == slot.satelliteId }
?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван") ?: throw CustomValidationException("KA ${slot.satelliteId} не зарегистророван")
logComplanCandidateBookedSlotOverlaps(
runId = runId,
cell = cell,
slot = slot,
existingSurveys = satellite.longMission.surveys
)
val decision = surveyPlacementService.tryPlaceSurvey( val decision = surveyPlacementService.tryPlaceSurvey(
existingSurveys = satellite.longMission.surveys, existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode( candidateSurvey = SurveyMode(
@@ -425,6 +492,45 @@ class ComplexPlanCalculationService(
return acceptedModes return acceptedModes
} }
private fun logComplanCandidateBookedSlotOverlaps(
runId: Long?,
cell: EarthCellWithRequestsDTO,
slot: SlotDTO,
existingSurveys: List<SurveyMode>
) {
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( private fun hasRemainingCapacity(
workingSatellites: List<SatelliteModel>, workingSatellites: List<SatelliteModel>,
intervalStart: LocalDateTime, intervalStart: LocalDateTime,
@@ -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.SlotCoverageStrategy
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotCoverageTargetDTO 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.SlotDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SurveySlotDTO
import tools.jackson.databind.ObjectMapper import tools.jackson.databind.ObjectMapper
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -161,16 +162,40 @@ class CoverageBatchClient(webClientBuilderProvider: ObjectProvider<WebClient.Bui
} }
} }
val rawCandidateSlots = result.response.sumOf { it.slots.size }
val filteredResponse = removeBookedSlotsFromCoverageResponse(result.response)
val filteredCandidateSlots = filteredResponse.sumOf { it.slots.size }
val removedBookedSlots = rawCandidateSlots - filteredCandidateSlots
logger.info( logger.info(
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}", "slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlotsRaw={}, candidateSlotsAfterBookedFilter={}, removedBookedSlots={}, durationMs={}, effectiveCoverageStrategy={}",
targets.size, targets.size,
result.response.size, filteredResponse.size,
result.response.sumOf { it.slots.size }, rawCandidateSlots,
filteredCandidateSlots,
removedBookedSlots,
result.durationMs, result.durationMs,
result.coverageStrategy result.coverageStrategy
) )
return result.response.associate { it.targetId to it.slots } return filteredResponse.associate { it.targetId to it.slots }
}
/**
* Complex plan loads booked slots before asking slots-service for coverage candidates.
* slots-service still has to see booked slots internally: it marks them as BOOKED and the
* coverage solver subtracts their geometry from the uncovered target area. However these
* BOOKED slots are already present in the working complex plan and must not be returned
* to chunk processing again as COMPLAN candidates.
*/
private fun removeBookedSlotsFromCoverageResponse(
response: List<SlotCoverageBatchResponseDTO>
): List<SlotCoverageBatchResponseDTO> =
response.map { targetResponse ->
targetResponse.copy(
slots = targetResponse.slots.filterNot { slot -> slot.state != SlotStatus.AVAILABLE }
)
} }
private fun requestCoverageModes( private fun requestCoverageModes(
@@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder() private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
private val logger = LoggerFactory.getLogger(this::class.java) private val logger = LoggerFactory.getLogger(this::class.java)
companion object {
private const val LOG_INTERVAL_SAMPLE_LIMIT = 10
}
@Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}") @Value("\${settings.coverage-scheme-service:pcp-coverage-scheme-service}")
val url = "" val url = ""
@@ -55,6 +59,16 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
intervalStart, intervalStart,
intervalEnd intervalEnd
) )
if (occupiedIntervals.isNotEmpty()) {
logger.warn(
"coverage-scheme-service client received occupiedIntervals but does not pass them to coverage-scheme-service request: occupiedIntervals={}, bySource={}, sample={}",
occupiedIntervals.size,
occupiedIntervals.groupingBy { it.source ?: "UNKNOWN" }.eachCount(),
occupiedIntervals.take(LOG_INTERVAL_SAMPLE_LIMIT).map { interval ->
"satelliteId=${interval.satelliteId},start=${interval.startTime},end=${interval.endTime},roll=${interval.roll},source=${interval.source}"
}
)
}
val result = linkedMapOf<Long, List<SlotDTO>>() val result = linkedMapOf<Long, List<SlotDTO>>()
val durationMs = measureTimeMillis { val durationMs = measureTimeMillis {
@@ -226,6 +226,9 @@ class SurveyPlacementService {
if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) { if (!canMergeInTime(existing.time, existing.timeStop, mergedSurvey.time, mergedSurvey.timeStop)) {
return@forEach return@forEach
} }
if (shouldSkipComplanInsideSlotsMerge(existing, mergedSurvey)) {
return@forEach
}
val overlapsInTime = intersectsInTime( val overlapsInTime = intersectsInTime(
existing.time, existing.time,
existing.timeStop, existing.timeStop,
@@ -377,6 +380,21 @@ class SurveyPlacementService {
return gapSeconds in 0..MERGE_MAX_TIME_GAP_SECONDS 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( private fun intersectsInTime(
start1: LocalDateTime, start1: LocalDateTime,
stop1: LocalDateTime, stop1: LocalDateTime,
@@ -121,6 +121,39 @@ class SurveyPlacementServiceTest {
assertFalse(surveys.single().contourWKT.startsWith("MULTIPOLYGON")) 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 @Test
fun `different roll conflict resolved by trimming`() { fun `different roll conflict resolved by trimming`() {
val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0)) val surveys = mutableListOf(survey(start = 51, end = 80, roll = 20.0))