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

This commit is contained in:
emelianov
2026-05-27 10:24:14 +03:00
parent 2aafa561ed
commit 1a6e51bf25
5 changed files with 205 additions and 23 deletions
@@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.model.satellite
import ballistics.types.InitialConditions import ballistics.types.InitialConditions
import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
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.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.BookedSlotEntity
@@ -40,10 +41,19 @@ class TestSatelliteImpl(
val selectedSlots = mutableListOf<PreparedCoverageSlot>() val selectedSlots = mutableListOf<PreparedCoverageSlot>()
val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc val cycleBegin = Duration.between(tnCalc, timeStart).toDays() / durationCalc
val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc val cycleEnd = Duration.between(tnCalc, timeStop).toDays() / durationCalc
val booked = bookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) } val activeBookedSlots = bookedSlots.filter { it.status in ACTIVE_BOOKED_STATUSES }
val bookedData = ( val booked = activeBookedSlots.map { BookedInfo(it.slot.slotId?:0, it.cycle) }
bookedSlots.map { BookedSlot(it.slot.tn, it.slot.tk,it.slot.roll, 0, it.cycle) }.toMutableList() val bookedData = activeBookedSlots.map { bookedSlot ->
) val offsetDays = bookedSlot.cycle * durationCalc
BookedSlot(
bookedSlot.slot.tn.plusDays(offsetDays),
bookedSlot.slot.tk.plusDays(offsetDays),
bookedSlot.slot.roll,
0,
bookedSlot.cycle
)
}.toMutableList()
val bookedIntervals = mergeBookedIntervals(bookedData.map { BookedInterval(it.t, it.tEnd) })
calculateChainLengths(bookedData) calculateChainLengths(bookedData)
for (cycleN in cycleBegin..cycleEnd) { for (cycleN in cycleBegin..cycleEnd) {
val currentSlots = slots.map { ent -> val currentSlots = slots.map { ent ->
@@ -68,24 +78,27 @@ class TestSatelliteImpl(
slot.tn >= timeStart && slot.tk <= timeStop && slot.tn >= timeStart && slot.tk <= timeStop &&
sign?.let { slot.revolutionSign == it.toString() } ?: true sign?.let { slot.revolutionSign == it.toString() } ?: true
}.map { }.map {
val band = bookedData.filter { booked -> val isBooked = booked.any { booked -> booked.cycle == it.cycle && booked.id == it.slotId }
val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd) val insideBookedInterval = !isBooked && bookedIntervals.any { booked -> booked.contains(it.tn, it.tk) }
val band = if (insideBookedInterval) {
true
} else {
bookedData.any { booked ->
val dif = intersection(it.tn.minusSeconds(mmi), it.tk.plusSeconds(mmi), booked.t, booked.tEnd)
val gam = abs(it.roll - booked.roll)
val gam = abs(it.roll - booked.roll) if (dif) {
!(gam < 0.01 && booked.neighbours < maxChainLength)
if (dif) { } else {
if (gam < 0.01 && booked.neighbours < maxChainLength) {
// if (gam < 0.01) {
// booked.neighbours++
false false
} else true }
} else false }
} }
PreparedCoverageSlot( PreparedCoverageSlot(
sourceSlotId = it.slotId ?: 0L, sourceSlotId = it.slotId ?: 0L,
slot = it.toDTO( slot = it.toDTO(
booked.count { booked -> booked.cycle == it.cycle && booked.id == it.slotId } > 0, isBooked,
band.isNotEmpty() band
) )
) )
}) })
@@ -95,6 +108,30 @@ class TestSatelliteImpl(
return selectedSlots return selectedSlots
} }
private data class BookedInterval(val start: LocalDateTime, val end: LocalDateTime) {
fun contains(slotStart: LocalDateTime, slotEnd: LocalDateTime): Boolean =
!slotStart.isBefore(start) && !slotEnd.isAfter(end)
}
private fun mergeBookedIntervals(intervals: List<BookedInterval>): List<BookedInterval> {
if (intervals.isEmpty()) {
return emptyList()
}
val merged = mutableListOf<BookedInterval>()
intervals.sortedBy { it.start }.forEach { interval ->
val last = merged.lastOrNull()
if (last != null && !interval.start.isAfter(last.end)) {
merged[merged.lastIndex] = BookedInterval(
start = minOf(last.start, interval.start),
end = maxOf(last.end, interval.end)
)
} else {
merged += interval
}
}
return merged
}
private fun calculateChainLengths(slots: MutableList<BookedSlot>) { private fun calculateChainLengths(slots: MutableList<BookedSlot>) {
if (slots.isEmpty()) if (slots.isEmpty())
return return
@@ -127,4 +164,12 @@ class TestSatelliteImpl(
slots[j].neighbours = lastChainLength slots[j].neighbours = lastChainLength
} }
} }
companion object {
private val ACTIVE_BOOKED_STATUSES = setOf(
BookedSlotStatus.BOOKED.name,
BookedSlotStatus.PROCESSED.name
)
}
} }
@@ -2,6 +2,7 @@ package space.nstart.pcp.slots_service.service
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.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.slots_service.model.satellite.AbstractSatellite import space.nstart.pcp.slots_service.model.satellite.AbstractSatellite
import java.time.Duration import java.time.Duration
import kotlin.math.abs import kotlin.math.abs
@@ -71,6 +72,15 @@ internal object OccupiedIntervalPrefilter {
occupiedIntervals: List<OccupiedIntervalDTO>, occupiedIntervals: List<OccupiedIntervalDTO>,
satellite: AbstractSatellite satellite: AbstractSatellite
): Boolean { ): Boolean {
val insideStrictReservedInterval = candidate.state != SlotStatus.BOOKED &&
occupiedIntervals.any { occupied ->
isStrictReservedSource(occupied.source) &&
isInside(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime)
}
if (insideStrictReservedInterval) {
return false
}
val conflictingDifferentRoll = occupiedIntervals.any { occupied -> val conflictingDifferentRoll = occupiedIntervals.any { occupied ->
!sameRoll(candidate.roll, occupied.roll) && !sameRoll(candidate.roll, occupied.roll) &&
intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi) intersectsWithMmi(candidate.tn, candidate.tk, occupied.startTime, occupied.endTime, satellite.mmi)
@@ -101,5 +111,15 @@ internal object OccupiedIntervalPrefilter {
mmi: Long mmi: Long
): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi) ): Boolean = start1 <= end2.plusSeconds(mmi) && end1 >= start2.minusSeconds(mmi)
private fun isInside(
start: java.time.LocalDateTime,
end: java.time.LocalDateTime,
containerStart: java.time.LocalDateTime,
containerEnd: java.time.LocalDateTime
): Boolean = !start.isBefore(containerStart) && !end.isAfter(containerEnd)
private fun isStrictReservedSource(source: String?): Boolean =
source.equals("SLOTS", ignoreCase = true)
private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON private fun sameRoll(left: Double, right: Double): Boolean = abs(left - right) <= ROLL_EPSILON
} }
@@ -1686,16 +1686,26 @@ class SlotService {
val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach val satellite = batchContext.selectedSatellitesById[satelliteId] ?: return@forEach
val preparedSlotsBySourceSlotId = val preparedSlotsBySourceSlotId =
batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty() batchContext.preparedSlotsBySatelliteIdAndSourceSlotId[satelliteId].orEmpty()
val preparedSlots = sourceSlotIdsForSatellite.flatMap { slotId -> val preparedSlotsIncludingBlocked = sourceSlotIdsForSatellite.flatMap { slotId ->
preparedSlotsBySourceSlotId[slotId].orEmpty() preparedSlotsBySourceSlotId[slotId].orEmpty()
}.map { it.slot } }.map { it.slot }
preparedSlotsBeforeOccupiedFilter += preparedSlots.size preparedSlotsBeforeOccupiedFilter += preparedSlotsIncludingBlocked.size
// BUSY слоты возникают, в частности, когда слот по времени попадает внутрь
// активного забронированного интервала. Такие слоты не должны попадать
// в дальнейший расчет покрытия: booked-слоты учитываются как BOOKED,
// а все остальные слоты внутри их времени считаются заблокированными.
val preparedSlots = preparedSlotsIncludingBlocked.filter { slot ->
slot.state == SlotStatus.AVAILABLE || slot.state == SlotStatus.BOOKED
}
val blockedByBookedSlots = preparedSlotsIncludingBlocked.size - preparedSlots.size
val filteredSlots = OccupiedIntervalPrefilter.filterCandidates( val filteredSlots = OccupiedIntervalPrefilter.filterCandidates(
candidateSlots = preparedSlots, candidateSlots = preparedSlots,
occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(), occupiedIntervals = batchContext.occupiedIntervalsBySatelliteId[satelliteId].orEmpty(),
satellite = satellite satellite = satellite
) )
occupiedFilteredSlots += preparedSlots.size - filteredSlots.size occupiedFilteredSlots += blockedByBookedSlots + (preparedSlots.size - filteredSlots.size)
candidateSlotsBeforeSunFilter += filteredSlots.size candidateSlotsBeforeSunFilter += filteredSlots.size
val sunFilterResult = filterBySunAngle(filteredSlots, sun) val sunFilterResult = filterBySunAngle(filteredSlots, sun)
sunFilteredSlots += sunFilterResult.filteredCount sunFilteredSlots += sunFilterResult.filteredCount
@@ -0,0 +1,62 @@
package space.nstart.pcp.slots_service.model.satellite
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus
import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus
import space.nstart.pcp.slots_service.entity.BookedSlotEntity
import space.nstart.pcp.slots_service.entity.SlotEntity
import java.time.LocalDateTime
import kotlin.test.assertEquals
class TestSatelliteImplBookedBlockingTest {
@Test
fun `non-booked slot inside active booked interval is marked busy and exact booked slot stays booked`() {
val baseTime = LocalDateTime.of(2026, 5, 21, 0, 0)
val satellite = TestSatelliteImpl(
id = 1L,
tnCalc = baseTime,
durationCalc = 1,
maxChainLength = 3,
maxSurveyDuration = 300,
mmi = 10
)
val bookedRawSlot = slot(slotId = 1L, slotNum = 1L, baseTime = baseTime, startSecond = 0, endSecond = 10)
val candidateInsideBooked = slot(slotId = 2L, slotNum = 2L, baseTime = baseTime, startSecond = 2, endSecond = 8)
val booked = BookedSlotEntity(slot = bookedRawSlot, cycle = 0).apply {
status = BookedSlotStatus.PROCESSED.name
}
val result = satellite.prepareCoverageSlots(
slots = listOf(bookedRawSlot, candidateInsideBooked),
bookedSlots = listOf(booked),
timeStart = baseTime,
timeStop = baseTime.plusSeconds(20),
sign = null,
states = null
).map { it.slot }.associateBy { it.slotNumber }
assertEquals(SlotStatus.BOOKED, result.getValue(1L).state)
assertEquals(SlotStatus.BUSY, result.getValue(2L).state)
}
private fun slot(
slotId: Long,
slotNum: Long,
baseTime: LocalDateTime,
startSecond: Long,
endSecond: Long
) = SlotEntity(
slotId = slotId,
slotNum = slotNum,
cycle = 0,
satelliteId = 1L,
coveringType = 0,
tn = baseTime.plusSeconds(startSecond),
tk = baseTime.plusSeconds(endSecond),
roll = 10.0,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
revolution = 1L,
revolutionSign = "ASC"
)
}
@@ -81,6 +81,48 @@ class OccupiedIntervalPrefilterTest {
assertEquals(listOf(candidate), filtered) assertEquals(listOf(candidate), filtered)
} }
@Test
fun `available candidate inside reserved slots interval is rejected even with same roll`() {
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
val candidate = candidateSlot(roll = 10.0, startOffsetSeconds = 10, endOffsetSeconds = 20)
val occupied = listOf(
occupiedInterval(
roll = 10.0,
startOffsetSeconds = 0,
endOffsetSeconds = 30,
source = "SLOTS"
)
)
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertTrue(filtered.isEmpty())
}
@Test
fun `booked candidate inside reserved slots interval is kept for coverage accounting`() {
val satellite = testSatellite(maxSurveyDuration = 120, mmi = 5)
val candidate = candidateSlot(
roll = 10.0,
startOffsetSeconds = 10,
endOffsetSeconds = 20,
state = SlotStatus.BOOKED
)
val occupied = listOf(
occupiedInterval(
roll = 10.0,
startOffsetSeconds = 0,
endOffsetSeconds = 30,
source = "SLOTS"
)
)
val filtered = OccupiedIntervalPrefilter.filterCandidates(listOf(candidate), occupied, satellite)
assertEquals(listOf(candidate), filtered)
}
private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite = private fun testSatellite(maxSurveyDuration: Int, mmi: Long): AbstractSatellite =
object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) { object : AbstractSatellite(id = 22, maxSurveyDuration = maxSurveyDuration, mmi = mmi) {
override fun prepareCoverageSlots( override fun prepareCoverageSlots(
@@ -96,26 +138,29 @@ class OccupiedIntervalPrefilterTest {
private fun occupiedInterval( private fun occupiedInterval(
roll: Double, roll: Double,
startOffsetSeconds: Long, startOffsetSeconds: Long,
endOffsetSeconds: Long endOffsetSeconds: Long,
source: String = "TEST"
) = OccupiedIntervalDTO( ) = OccupiedIntervalDTO(
satelliteId = 22, satelliteId = 22,
startTime = baseTime().plusSeconds(startOffsetSeconds), startTime = baseTime().plusSeconds(startOffsetSeconds),
endTime = baseTime().plusSeconds(endOffsetSeconds), endTime = baseTime().plusSeconds(endOffsetSeconds),
roll = roll, roll = roll,
source = "TEST" source = source
) )
private fun candidateSlot( private fun candidateSlot(
roll: Double, roll: Double,
startOffsetSeconds: Long, startOffsetSeconds: Long,
endOffsetSeconds: Long endOffsetSeconds: Long,
state: SlotStatus = SlotStatus.AVAILABLE
) = SlotDTO( ) = SlotDTO(
cycle = 0, cycle = 0,
satelliteId = 22, satelliteId = 22,
tn = baseTime().plusSeconds(startOffsetSeconds), tn = baseTime().plusSeconds(startOffsetSeconds),
tk = baseTime().plusSeconds(endOffsetSeconds), tk = baseTime().plusSeconds(endOffsetSeconds),
roll = roll, roll = roll,
contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" contour = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
state = state
) )
private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0) private fun baseTime(): LocalDateTime = LocalDateTime.of(2026, 3, 26, 10, 0, 0)