Учет забронированных слотов при расчете комплексного плана
This commit is contained in:
+113
-7
@@ -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,
|
||||
|
||||
+29
-4
@@ -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(
|
||||
|
||||
+14
@@ -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 {
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
+33
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user