diff --git a/core/src/main/java/fr/sncf/osrd/conflicts/IncrementalConflictDetector.kt b/core/src/main/java/fr/sncf/osrd/conflicts/IncrementalConflictDetector.kt index 1997b24707b..600b727ab3d 100644 --- a/core/src/main/java/fr/sncf/osrd/conflicts/IncrementalConflictDetector.kt +++ b/core/src/main/java/fr/sncf/osrd/conflicts/IncrementalConflictDetector.kt @@ -1,301 +1,154 @@ package fr.sncf.osrd.conflicts -import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.Conflict -import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.Conflict.ConflictType -import fr.sncf.osrd.api.ConflictDetectionEndpoint.ConflictDetectionResult.ConflictRequirement -import fr.sncf.osrd.standalone_sim.result.ResultTrain.RoutingRequirement +import com.google.common.collect.Range +import com.google.common.collect.RangeSet +import com.google.common.collect.TreeRangeSet import fr.sncf.osrd.standalone_sim.result.ResultTrain.SpacingRequirement +import java.util.TreeMap import kotlin.math.max import kotlin.math.min const val DEFAULT_WORK_SCHEDULE_ID: Long = -1 -data class ConflictProperties( - // If there are conflicts, minimum delay that should be added to the train so that there are no - // conflicts anymore +sealed interface IncrementalConflictResponse + +data class ConflictResponse( + // minimum delay that should be added to the train so that there are no conflicts anymore val minDelayWithoutConflicts: Double, - // If there are no conflicts, maximum delay that can be added to the train without creating any - // conflict + // Time at which the first conflict happened + val firstConflictTime: Double, +) : IncrementalConflictResponse + +data class NoConflictResponse( + // maximum delay that can be added to the train without creating any conflict val maxDelayWithoutConflicts: Double, - // If there are no conflicts, minimum begin time of the next requirement that could conflict + // minimum begin time of the next requirement that could conflict val timeOfNextConflict: Double, - val hasConflict: Boolean, - val firstConflictTime: Double?, -) +) : IncrementalConflictResponse fun incrementalConflictDetectorFromTrainReq( requirements: List ): IncrementalConflictDetector { - return IncrementalConflictDetectorImpl(convertTrainRequirements(requirements)) + return IncrementalConflictDetector(convertTrainRequirements(requirements)) } fun incrementalConflictDetectorFromReq( requirements: List ): IncrementalConflictDetector { - return IncrementalConflictDetectorImpl(requirements) + return IncrementalConflictDetector(requirements) } -interface IncrementalConflictDetector { - fun analyseConflicts( - spacingRequirements: List, - routingRequirements: List - ): ConflictProperties -} - -class IncrementalConflictDetectorImpl(requirements: List) : - IncrementalConflictDetector { - private val spacingZoneRequirements = - mutableMapOf>() - private val routingZoneRequirements = - mutableMapOf>() +/** + * This class takes a list of requirements as input, and can only be used to compare them to a *new* + * set of requirements. The initial requirements cannot be modified. Conflicts between initial + * trains are not tested. + * + * In practice, this is used for STDCM, where the initial requirements represent the timetable + * trains, and new requirements come from the train we are trying to fit in the timetable. + */ +class IncrementalConflictDetector(requirements: List) { + // Zone name -> (end time -> Range(start time, end time)). + // The range is partially redundant, but it makes for easier and clearer processing. + private val spacingZoneUses: Map>> init { - generateSpacingRequirements(requirements) - generateRoutingRequirements(requirements) + spacingZoneUses = generateSpacingZoneUses(requirements) } - data class SpacingZoneRequirement( - val id: RequirementId, - override val beginTime: Double, - override val endTime: Double, - ) : ResourceRequirement - - private fun generateSpacingRequirements(requirements: List) { - // organize requirements by zone + private fun generateSpacingZoneUses( + requirements: List + ): Map>> { + // We first create RangeSets to handle the overlaps, but then + // convert them to TreeMaps (`.higherEntry` is extremely convenient here) + val rangeSets = mutableMapOf>() for (req in requirements) { for (spacingReq in req.spacingRequirements) { - val zoneReq = - SpacingZoneRequirement(req.id, spacingReq.beginTime, spacingReq.endTime) - spacingZoneRequirements.getOrPut(spacingReq.zone!!) { mutableListOf() }.add(zoneReq) - } - } - } - - data class RoutingZoneConfig( - val entryDet: String, - val exitDet: String, - val switches: Map - ) - - data class RoutingZoneRequirement( - val trainId: Long, - val route: String, - override val beginTime: Double, - override val endTime: Double, - val config: RoutingZoneConfig, - ) : ResourceRequirement - - private fun generateRoutingRequirements(requirements: List) { - // reorganize requirements by zone - for (trainRequirements in requirements) { - val trainId = trainRequirements.id.id - for (routeRequirements in trainRequirements.routingRequirements) { - val route = routeRequirements.route!! - var beginTime = routeRequirements.beginTime - // TODO: make it a parameter - if (routeRequirements.zones.any { it.switches.isNotEmpty() }) beginTime -= 5.0 - for (zoneRequirement in routeRequirements.zones) { - val endTime = zoneRequirement.endTime - val config = - RoutingZoneConfig( - zoneRequirement.entryDetector, - zoneRequirement.exitDetector, - zoneRequirement.switches!! - ) - val requirement = - RoutingZoneRequirement(trainId, route, beginTime, endTime, config) - routingZoneRequirements - .getOrPut(zoneRequirement.zone) { mutableListOf() } - .add(requirement) - } - } - } - } - - private fun checkConflicts( - spacingRequirements: List, - routingRequirements: List - ): List { - val res = mutableListOf() - for (spacingRequirement in spacingRequirements) { - res.addAll(checkSpacingRequirement(spacingRequirement)) - } - for (routingRequirement in routingRequirements) { - res.addAll(checkRoutingRequirement(routingRequirement)) - } - return res - } - - private fun checkSpacingRequirement(req: SpacingRequirement): List { - val requirements = spacingZoneRequirements[req.zone] ?: return listOf() - - val res = mutableListOf() - for (otherReq in requirements) { - val beginTime = max(req.beginTime, otherReq.beginTime) - val endTime = min(req.endTime, otherReq.endTime) - if (beginTime < endTime) { - val trainIds = mutableListOf() - val workScheduleIds = mutableListOf() - if (otherReq.id.type == RequirementType.WORK_SCHEDULE) - workScheduleIds.add(otherReq.id.id) - else trainIds.add(otherReq.id.id) - val conflictReq = ConflictRequirement(req.zone, beginTime, endTime) - res.add( - Conflict( - trainIds, - workScheduleIds, - beginTime, - endTime, - ConflictType.SPACING, - listOf(conflictReq) - ) - ) + val set = rangeSets.computeIfAbsent(spacingReq.zone) { TreeRangeSet.create() } + set.add(Range.closedOpen(spacingReq.beginTime, spacingReq.endTime)) } } - - return res + return rangeSets + .map { it.key to TreeMap(it.value.asRanges().associate { it.upperEndpoint() to it }) } + .toMap() } - fun checkRoutingRequirement(req: RoutingRequirement): List { - val res = mutableListOf() - for (zoneReq in req.zones) { - val zoneReqConfig = - RoutingZoneConfig(zoneReq.entryDetector, zoneReq.exitDetector, zoneReq.switches!!) - val requirements = routingZoneRequirements[zoneReq.zone!!] ?: continue - - for (otherReq in requirements) { - if (otherReq.config == zoneReqConfig) continue - val beginTime = max(req.beginTime, otherReq.beginTime) - val endTime = min(zoneReq.endTime, otherReq.endTime) - val conflictReq = ConflictRequirement(zoneReq.zone, beginTime, endTime) - if (beginTime < endTime) - res.add( - Conflict( - listOf(otherReq.trainId), - beginTime, - endTime, - ConflictType.ROUTING, - listOf(conflictReq) - ) - ) - } - } - return res - } - - override fun analyseConflicts( + /** + * Checks for any conflict between the initial requirements and the ones given here as method + * input. Returns a polymorphic response with different extra data for either case (conflict / + * no conflict). + */ + fun analyseConflicts( spacingRequirements: List, - routingRequirements: List - ): ConflictProperties { - val conflicts = checkConflicts(spacingRequirements, routingRequirements) // TODO remove this - val minDelayWithoutConflicts = - minDelayWithoutConflicts(spacingRequirements, routingRequirements) + ): IncrementalConflictResponse { + val minDelayWithoutConflicts = minDelayWithoutConflicts(spacingRequirements) if (minDelayWithoutConflicts != 0.0) { // There are initial conflicts - return ConflictProperties( + return ConflictResponse( minDelayWithoutConflicts, - 0.0, - 0.0, - true, - conflicts.first().startTime, + earliestConflictTime(spacingRequirements), ) } else { // There are no initial conflicts var maxDelay = Double.POSITIVE_INFINITY var timeOfNextConflict = Double.POSITIVE_INFINITY for (spacingRequirement in spacingRequirements) { - if (spacingZoneRequirements[spacingRequirement.zone!!] != null) { - val endTime = spacingRequirement.endTime - for (requirement in spacingZoneRequirements[spacingRequirement.zone!!]!!) { - if (endTime <= requirement.beginTime) { - maxDelay = min(maxDelay, requirement.beginTime - endTime) - timeOfNextConflict = min(timeOfNextConflict, requirement.beginTime) - } - } - } - } - for (routingRequirement in routingRequirements) { - for (zoneReq in routingRequirement.zones) { - if (routingZoneRequirements[zoneReq.zone!!] != null) { - val endTime = zoneReq.endTime - val config = - RoutingZoneConfig( - zoneReq.entryDetector, - zoneReq.exitDetector, - zoneReq.switches!! - ) - for (requirement in routingZoneRequirements[zoneReq.zone!!]!!) { - if (endTime <= requirement.beginTime && config != requirement.config) { - maxDelay = min(maxDelay, requirement.beginTime - endTime) - timeOfNextConflict = min(timeOfNextConflict, requirement.beginTime) - } - } - } - } + val map = spacingZoneUses[spacingRequirement.zone] ?: continue + val entry = map.higherEntry(spacingRequirement.beginTime) ?: continue + val nextUse = entry.value.lowerEndpoint() + maxDelay = min(maxDelay, nextUse - spacingRequirement.endTime) + timeOfNextConflict = min(timeOfNextConflict, nextUse) } - return ConflictProperties( - minDelayWithoutConflicts, + return NoConflictResponse( maxDelay, timeOfNextConflict, - false, - null, ) } } - fun minDelayWithoutConflicts( + /** + * Returns the earliest time at which there is a conflict (a resource is used by the new train + * and in the initial requirements). + */ + private fun earliestConflictTime( spacingRequirements: List, - routingRequirements: List ): Double { - var globalMinDelay = 0.0 - while (globalMinDelay.isFinite()) { - var minDelay = 0.0 + var res = Double.POSITIVE_INFINITY + for (spacingRequirement in spacingRequirements) { + val map = spacingZoneUses[spacingRequirement.zone] ?: continue + val entry = map.higherEntry(spacingRequirement.beginTime) ?: continue + if (entry.value.lowerEndpoint() > spacingRequirement.endTime) continue + val firstConflictTime = max(entry.value.lowerEndpoint(), spacingRequirement.beginTime) + res = min(res, firstConflictTime) + } + return res + } + + /** + * Returns the minimum amount of delay to add to the new requirements to avoid any conflict. May + * be infinite. 0 if no conflict. + */ + private fun minDelayWithoutConflicts( + spacingRequirements: List, + ): Double { + var minDelay = 0.0 + // We iterate until the requirements fit in the timetable, + // shifting them later whenever a conflict is detected. + // We stop once we go for a whole loop without conflict. + while (true) { + var hasIncreasedDelay = false for (spacingRequirement in spacingRequirements) { - if (spacingZoneRequirements[spacingRequirement.zone!!] != null) { - val conflictingRequirements = - spacingZoneRequirements[spacingRequirement.zone!!]!!.filter { - !(spacingRequirement.beginTime >= it.endTime || - spacingRequirement.endTime <= it.beginTime) - } - if (conflictingRequirements.isNotEmpty()) { - val latestEndTime = conflictingRequirements.maxOf { it.endTime } - minDelay = max(minDelay, latestEndTime - spacingRequirement.beginTime) - } - } - } - for (routingRequirement in routingRequirements) { - for (zoneReq in routingRequirement.zones) { - if (routingZoneRequirements[zoneReq.zone!!] != null) { - val config = - RoutingZoneConfig( - zoneReq.entryDetector, - zoneReq.exitDetector, - zoneReq.switches!! - ) - val conflictingRequirements = - routingZoneRequirements[zoneReq.zone!!]!!.filter { - !(routingRequirement.beginTime >= it.endTime || - zoneReq.endTime <= it.beginTime) && config != it.config - } - if (conflictingRequirements.isNotEmpty()) { - val latestEndTime = conflictingRequirements.maxOf { it.endTime } - minDelay = max(minDelay, latestEndTime - routingRequirement.beginTime) - } - } - } - } - // No new conflicts - if (minDelay == 0.0) return globalMinDelay + val map = spacingZoneUses[spacingRequirement.zone] ?: continue - // Check for conflicts with newly added delay - globalMinDelay += minDelay - spacingRequirements.onEach { - it.beginTime += minDelay - it.endTime += minDelay - } - routingRequirements.onEach { routingRequirement -> - routingRequirement.beginTime += minDelay - routingRequirement.zones.onEach { it.endTime += minDelay } + val requirementStart = minDelay + spacingRequirement.beginTime + val requirementEnd = minDelay + spacingRequirement.endTime + + val firstEntryStartAfter = map.higherEntry(requirementStart) ?: continue + if (firstEntryStartAfter.value.lowerEndpoint() >= requirementEnd) continue + + val extraDelay = firstEntryStartAfter.value.upperEndpoint() - requirementStart + minDelay += extraDelay + hasIncreasedDelay = true } + // No new conflicts + if (!hasIncreasedDelay || minDelay.isInfinite()) return minDelay } - return Double.POSITIVE_INFINITY } } diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMEndpointV2.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMEndpointV2.kt index 2a7f455a6b6..bc2f53e9507 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMEndpointV2.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMEndpointV2.kt @@ -355,8 +355,10 @@ private fun checkForConflicts( } val conflictDetector = incrementalConflictDetectorFromReq(timetableTrainRequirements) val spacingRequirements = parseSpacingRequirements(newTrainSpacingRequirement) - val conflicts = conflictDetector.analyseConflicts(spacingRequirements, listOf()) - assert(!conflicts.hasConflict) { "STDCM result is conflicting with the scheduled timetable" } + val conflicts = conflictDetector.analyseConflicts(spacingRequirements) + assert(conflicts is NoConflictResponse) { + "STDCM result is conflicting with the scheduled timetable" + } } private fun findWaypointBlocks( diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/stdcm/STDCMEndpoint.kt b/core/src/main/kotlin/fr/sncf/osrd/api/stdcm/STDCMEndpoint.kt index a817b132aa9..773751dcd7b 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/stdcm/STDCMEndpoint.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/stdcm/STDCMEndpoint.kt @@ -172,9 +172,8 @@ private fun checkForConflicts( it.isComplete ) }, - simResult.routingRequirements.map { - ResultTrain.RoutingRequirement(it.route, it.beginTime + departureTime, it.zones) - } ) - assert(!conflicts.hasConflict) { "STDCM result is conflicting with the scheduled timetable" } + assert(conflicts is NoConflictResponse) { + "STDCM result is conflicting with the scheduled timetable" + } } diff --git a/core/src/main/kotlin/fr/sncf/osrd/stdcm/preprocessing/implementation/BlockAvailability.kt b/core/src/main/kotlin/fr/sncf/osrd/stdcm/preprocessing/implementation/BlockAvailability.kt index 56376d88e2b..382b76ed133 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/stdcm/preprocessing/implementation/BlockAvailability.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/stdcm/preprocessing/implementation/BlockAvailability.kt @@ -1,9 +1,6 @@ package fr.sncf.osrd.stdcm.preprocessing.implementation -import fr.sncf.osrd.conflicts.IncrementalConflictDetector -import fr.sncf.osrd.conflicts.TrainRequirements -import fr.sncf.osrd.conflicts.TravelledPath -import fr.sncf.osrd.conflicts.incrementalConflictDetectorFromTrainReq +import fr.sncf.osrd.conflicts.* import fr.sncf.osrd.envelope_utils.DoubleBinarySearch import fr.sncf.osrd.sim_infra.api.Path import fr.sncf.osrd.standalone_sim.result.ResultTrain.SpacingRequirement @@ -262,22 +259,25 @@ data class BlockAvailability( } .filter { it.beginTime < it.endTime } val conflictProperties = - incrementalConflictDetector.analyseConflicts(shiftedSpacingRequirements, listOf()) - if (!conflictProperties.hasConflict) { - return BlockAvailabilityInterface.Available( - conflictProperties.maxDelayWithoutConflicts, - conflictProperties.timeOfNextConflict - ) - } else { - val firstConflictOffset = - getEnvelopeOffsetFromTime( - infraExplorer, - conflictProperties.firstConflictTime!! - pathStartTime + incrementalConflictDetector.analyseConflicts(shiftedSpacingRequirements) + when (conflictProperties) { + is NoConflictResponse -> { + return BlockAvailabilityInterface.Available( + conflictProperties.maxDelayWithoutConflicts, + conflictProperties.timeOfNextConflict ) - return BlockAvailabilityInterface.Unavailable( - conflictProperties.minDelayWithoutConflicts, - firstConflictOffset - ) + } + is ConflictResponse -> { + val firstConflictOffset = + getEnvelopeOffsetFromTime( + infraExplorer, + conflictProperties.firstConflictTime - pathStartTime + ) + return BlockAvailabilityInterface.Unavailable( + conflictProperties.minDelayWithoutConflicts, + firstConflictOffset + ) + } } } diff --git a/core/src/test/kotlin/fr/sncf/osrd/conflicts/IncrementalConflictDetectorTests.kt b/core/src/test/kotlin/fr/sncf/osrd/conflicts/IncrementalConflictDetectorTests.kt new file mode 100644 index 00000000000..496cb40c842 --- /dev/null +++ b/core/src/test/kotlin/fr/sncf/osrd/conflicts/IncrementalConflictDetectorTests.kt @@ -0,0 +1,63 @@ +package fr.sncf.osrd.conflicts + +import fr.sncf.osrd.standalone_sim.result.ResultTrain +import kotlin.test.Test +import kotlin.test.assertEquals + +class IncrementalConflictDetectorTests { + data class SimpleRequirement( + val zoneId: Int, + val start: Double, + val end: Double, + ) + + @Test + fun simpleAvailableTest() { + val detector = + makeDetector( + SimpleRequirement(0, 0.0, 1_000.0), + SimpleRequirement(0, 2_000.0, 3_000.0), + ) + val res = + checkConflict(detector, SimpleRequirement(0, 1_200.0, 1_400.0)) as NoConflictResponse + assertEquals(600.0, res.maxDelayWithoutConflicts) + assertEquals(2_000.0, res.timeOfNextConflict) + } + + @Test + fun simpleNoAvailableTest() { + val detector = + makeDetector( + SimpleRequirement(0, 0.0, 900.0), + SimpleRequirement(0, 1_100.0, 2_000.0), + ) + val res = checkConflict(detector, SimpleRequirement(0, 200.0, 450.0)) as ConflictResponse + assertEquals(200.0, res.firstConflictTime) + assertEquals(1_800.0, res.minDelayWithoutConflicts) + } + + fun makeDetector(vararg requirements: SimpleRequirement): IncrementalConflictDetector { + return IncrementalConflictDetector( + listOf( + Requirements( + RequirementId(0, RequirementType.TRAIN), + requirements.map { + ResultTrain.SpacingRequirement(it.zoneId.toString(), it.start, it.end, true) + }, + listOf() + ) + ) + ) + } + + fun checkConflict( + detector: IncrementalConflictDetector, + vararg requirements: SimpleRequirement + ): IncrementalConflictResponse { + return detector.analyseConflicts( + requirements.map { + ResultTrain.SpacingRequirement(it.zoneId.toString(), it.start, it.end, true) + }, + ) + } +}