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

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.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<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>()
var coverageByTargetId = emptyMap<Long, List<SlotDTO>>()
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<SatelliteModel>,
intervalStart: LocalDateTime,
intervalEnd: LocalDateTime
@@ -309,9 +320,8 @@ class ComplexPlanCalculationService(
workingSatellites.forEach { satellite ->
val plannedForSatellite = plannedModesBySatelliteId[satellite.satelliteId].orEmpty()
plannedModesBySatelliteId[satellite.satelliteId]
.orEmpty()
.forEach { slot ->
logBookedSlotsLoaded(runId, satellite.satelliteId, plannedForSatellite)
plannedForSatellite.forEach { slot ->
surveyPlacementService.mergeSurvey(
existingSurveys = satellite.longMission.surveys,
candidateSurvey = SurveyMode(
@@ -332,7 +342,8 @@ class ComplexPlanCalculationService(
)
}
logger.info(
"Добавлены booked-маршруты для satelliteId={}: count={}",
"Добавлены booked-маршруты: runId={}, satelliteId={}, count={}",
runId,
satellite.satelliteId,
plannedForSatellite.size
)
@@ -367,10 +378,59 @@ class ComplexPlanCalculationService(
.sortedWith(compareBy<OccupiedIntervalDTO> { it.satelliteId }.thenBy { it.startTime }.thenBy { it.endTime })
.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(
runId: Long?,
workingSatellites: List<SatelliteModel>,
cells: List<EarthCellWithRequestsDTO>,
coverageByTargetId: Map<Long, List<space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotDTO>>,
coverageByTargetId: Map<Long, List<SlotDTO>>,
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<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(
workingSatellites: List<SatelliteModel>,
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.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,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(
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlots={}, durationMs={}, effectiveCoverageStrategy={}",
"slots-service coverage batch finish: targets={}, returnedTargets={}, candidateSlotsRaw={}, candidateSlotsAfterBookedFilter={}, removedBookedSlots={}, durationMs={}, effectiveCoverageStrategy={}",
targets.size,
result.response.size,
result.response.sumOf { it.slots.size },
filteredResponse.size,
rawCandidateSlots,
filteredCandidateSlots,
removedBookedSlots,
result.durationMs,
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(
@@ -27,6 +27,10 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
private val webClientBuilder: WebClient.Builder = webClientBuilderProvider.ifAvailable ?: WebClient.builder()
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}")
val url = ""
@@ -55,6 +59,16 @@ class CoverageSchemeCalculationClient(webClientBuilderProvider: ObjectProvider<W
intervalStart,
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 durationMs = measureTimeMillis {
@@ -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,
@@ -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))