From a20af1c17707b55cb905e4e4ea1cb812aaaa1f0a Mon Sep 17 00:00:00 2001 From: Erashin Date: Wed, 17 Jul 2024 10:01:29 +0200 Subject: [PATCH] core: take step planned timing data into account in block availability --- .../osrd/api/api_v2/stdcm/STDCMEndpointV2.kt | 23 +- .../osrd/api/api_v2/stdcm/STDCMRequestV2.kt | 4 - .../kotlin/fr/sncf/osrd/stdcm/STDCMStep.kt | 11 +- .../implementation/BlockAvailability.kt | 246 ++++++++++++- .../preprocessing/BlockAvailabilityTests.kt | 334 +++++++++++++++++- 5 files changed, 595 insertions(+), 23 deletions(-) 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 d93813cde2d..a7c68252ce6 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 @@ -25,6 +25,7 @@ import fr.sncf.osrd.standalone_sim.makeElectricalProfiles import fr.sncf.osrd.standalone_sim.makeMRSPResponse import fr.sncf.osrd.standalone_sim.result.ElectrificationRange import fr.sncf.osrd.standalone_sim.runScheduleMetadataExtractor +import fr.sncf.osrd.stdcm.PlannedTimingData import fr.sncf.osrd.stdcm.STDCMResult import fr.sncf.osrd.stdcm.STDCMStep import fr.sncf.osrd.stdcm.graph.findPath @@ -33,9 +34,12 @@ import fr.sncf.osrd.train.RollingStock import fr.sncf.osrd.train.TrainStop import fr.sncf.osrd.utils.Direction import fr.sncf.osrd.utils.units.Offset +import fr.sncf.osrd.utils.units.TimeDelta import fr.sncf.osrd.utils.units.meters import fr.sncf.osrd.utils.units.seconds +import java.time.Duration.between import java.time.Duration.ofMillis +import java.time.ZonedDateTime import org.takes.Request import org.takes.Response import org.takes.Take @@ -67,6 +71,7 @@ class STDCMEndpointV2(private val infraManager: InfraManager) : Take { val trainsRequirements = parseRawTrainsRequirements(request.trainsRequirements, request.startTime) val spacingRequirements = trainsRequirements.flatMap { it.spacingRequirements } + val steps = parseSteps(infra, request.pathItems, request.startTime) // Run the STDCM pathfinding val path = @@ -75,11 +80,12 @@ class STDCMEndpointV2(private val infraManager: InfraManager) : Take { rollingStock, request.comfort, 0.0, - parseSteps(infra, request.pathItems), + steps, makeBlockAvailability( infra, spacingRequirements, workSchedules = request.workSchedules, + steps, gridMarginBeforeTrain = request.timeGapBefore.seconds, gridMarginAfterTrain = request.timeGapAfter.seconds ), @@ -173,7 +179,11 @@ class STDCMEndpointV2(private val infraManager: InfraManager) : Take { } } -private fun parseSteps(infra: FullInfra, pathItems: List): List { +private fun parseSteps( + infra: FullInfra, + pathItems: List, + startTime: ZonedDateTime +): List { if (pathItems.last().stopDuration == null) { throw OSRDError(ErrorType.MissingLastSTDCMStop) } @@ -182,7 +192,14 @@ private fun parseSteps(infra: FullInfra, pathItems: List): List) - data class WorkSchedule( /** List of affected track ranges */ @Json(name = "track_ranges") val trackRanges: Collection = listOf(), diff --git a/core/src/main/kotlin/fr/sncf/osrd/stdcm/STDCMStep.kt b/core/src/main/kotlin/fr/sncf/osrd/stdcm/STDCMStep.kt index 43c2f195095..56b8c49a67b 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/stdcm/STDCMStep.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/stdcm/STDCMStep.kt @@ -2,9 +2,18 @@ package fr.sncf.osrd.stdcm import fr.sncf.osrd.graph.PathfindingEdgeLocationId import fr.sncf.osrd.sim_infra.api.Block +import fr.sncf.osrd.utils.units.Duration +import fr.sncf.osrd.utils.units.TimeDelta data class STDCMStep( val locations: Collection>, val duration: Double?, - val stop: Boolean + val stop: Boolean, + val plannedTimingData: PlannedTimingData? = null, +) + +data class PlannedTimingData( + val arrivalTime: TimeDelta, + val arrivalTimeToleranceBefore: Duration, + val arrivalTimeToleranceAfter: Duration, ) 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 0ce5ab29f50..997eac90402 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 @@ -10,6 +10,7 @@ import fr.sncf.osrd.envelope_utils.DoubleBinarySearch import fr.sncf.osrd.sim_infra.api.Path import fr.sncf.osrd.sim_infra.api.RawSignalingInfra import fr.sncf.osrd.standalone_sim.result.ResultTrain.SpacingRequirement +import fr.sncf.osrd.stdcm.STDCMStep import fr.sncf.osrd.stdcm.infra_exploration.InfraExplorerWithEnvelope import fr.sncf.osrd.stdcm.preprocessing.interfaces.BlockAvailabilityInterface import fr.sncf.osrd.utils.units.Distance @@ -23,8 +24,8 @@ import org.slf4j.LoggerFactory val blockAvailabilityLogger: Logger = LoggerFactory.getLogger("BlockAvailability") data class BlockAvailability( - val fullInfra: FullInfra, val incrementalConflictDetector: IncrementalConflictDetector, + val plannedSteps: List, val gridMarginBeforeTrain: Double, val gridMarginAfterTrain: Double, ) : BlockAvailabilityInterface { @@ -34,13 +35,198 @@ data class BlockAvailability( startOffset: Offset, endOffset: Offset, startTime: Double + ): BlockAvailabilityInterface.Availability { + var timeShift = 0.0 + var firstConflictOffset = infraExplorer.getIncrementalPath().toTravelledPath(startOffset) + while (timeShift.isFinite()) { + val shiftedStartTime = startTime + timeShift + val pathStartTime = + shiftedStartTime - infraExplorer.interpolateDepartureFromClamp(startOffset) + val endTime = infraExplorer.interpolateDepartureFromClamp(endOffset) + pathStartTime + + val stepAvailability = + getStepAvailability(infraExplorer, startOffset, endOffset, pathStartTime) + val conflictAvailability = + getConflictAvailability( + infraExplorer, + startOffset, + pathStartTime, + shiftedStartTime, + endTime + ) + val availability = + getMostRestrictiveAvailability(stepAvailability, conflictAvailability) + + when (availability) { + is BlockAvailabilityInterface.Available -> { + if ( + timeShift > 0.0 + ) { // Availability is available due to adding a delay: timeShift + return BlockAvailabilityInterface.Unavailable( + timeShift, + firstConflictOffset + ) + } + // Availability is directly available without adding any delay + return availability + } + is BlockAvailabilityInterface.Unavailable -> { + timeShift += availability.duration + firstConflictOffset = availability.firstConflictOffset + } + } + } + // No available solution with a finite delay was found + return BlockAvailabilityInterface.Unavailable(Double.POSITIVE_INFINITY, firstConflictOffset) + } + + /** + * Check that the planned step timing data is respected, and return the corresponding + * availability. + * - If some steps are not respected, find the minimumDelayToBecomeAvailable and return + * Unavailable(minimumDelayToBecomeAvailable, ...). In case this delay fails other steps, + * return Unavailable(Infinity, ...). + * - If all steps are respected, find the maximumDelayToStayAvailable and return + * Available(maximumDelayToStayAvailable, ...). + */ + private fun getStepAvailability( + infraExplorer: InfraExplorerWithEnvelope, + startOffset: Offset, + endOffset: Offset, + pathStartTime: Double, + ): BlockAvailabilityInterface.Availability { + var minimumDelayToBecomeAvailable = 0.0 + var firstUnavailabilityOffset = Offset(Double.POSITIVE_INFINITY.meters) + var maximumDelayToStayAvailable = Double.POSITIVE_INFINITY + var timeOfNextUnavailability = Double.POSITIVE_INFINITY + + for (step in plannedSteps) { + val availabilityProperties = + getStepAvailabilityProperties( + step, + infraExplorer, + startOffset, + endOffset, + pathStartTime + ) + if (availabilityProperties.minimumDelayToBecomeAvailable == Double.POSITIVE_INFINITY) { + return BlockAvailabilityInterface.Unavailable( + Double.POSITIVE_INFINITY, + availabilityProperties.firstUnavailabilityOffset + ) + } else if ( + availabilityProperties.minimumDelayToBecomeAvailable > minimumDelayToBecomeAvailable + ) { + minimumDelayToBecomeAvailable = availabilityProperties.minimumDelayToBecomeAvailable + firstUnavailabilityOffset = availabilityProperties.firstUnavailabilityOffset + } else if ( + availabilityProperties.maximumDelayToStayAvailable < maximumDelayToStayAvailable + ) { + maximumDelayToStayAvailable = availabilityProperties.maximumDelayToStayAvailable + timeOfNextUnavailability = availabilityProperties.timeOfNextUnavailability + } + } + if (minimumDelayToBecomeAvailable > 0.0) { // At least one planned step was not respected + if (minimumDelayToBecomeAvailable > maximumDelayToStayAvailable) { + // Adding minimumDelayToBecomeAvailable will make us step out of another planned + // step + return BlockAvailabilityInterface.Unavailable( + Double.POSITIVE_INFINITY, + firstUnavailabilityOffset + ) + } + // Adding minimumDelayToBecomeAvailable solves every planned step problem + return BlockAvailabilityInterface.Unavailable( + minimumDelayToBecomeAvailable, + firstUnavailabilityOffset + ) + } + // Every planned step was respected + return BlockAvailabilityInterface.Available( + maximumDelayToStayAvailable, + timeOfNextUnavailability + ) + } + + private fun getStepAvailabilityProperties( + step: STDCMStep, + infraExplorer: InfraExplorerWithEnvelope, + startOffset: Offset, + endOffset: Offset, + pathStartTime: Double + ): AvailabilityProperties { + val incrementalPath = infraExplorer.getIncrementalPath() + for (location in step.locations) { + // Iterate over all the predecessor blocks + current block + for (i in 0 until infraExplorer.getPredecessorBlocks().size + 1) { + if (location.edge == incrementalPath.getBlock(i)) { + // Only consider the blocks within the range formed by given offsets + if ( + (incrementalPath.getBlockStartOffset(i) + location.offset.distance in + startOffset..endOffset) + ) { + val stepOffsetOnPath = + incrementalPath.getBlockStartOffset(i) + location.offset.distance + val timeAtStep = + infraExplorer.interpolateDepartureFromClamp(stepOffsetOnPath) + + pathStartTime + val plannedMinTimeAtStep = + (step.plannedTimingData!!.arrivalTime - + step.plannedTimingData.arrivalTimeToleranceBefore) + .seconds + val plannedMaxTimeAtStep = + (step.plannedTimingData.arrivalTime + + step.plannedTimingData.arrivalTimeToleranceAfter) + .seconds + if (plannedMinTimeAtStep > timeAtStep) { + // Train passes through planned timing data before it is available + return AvailabilityProperties( + plannedMinTimeAtStep - timeAtStep, + incrementalPath.toTravelledPath(stepOffsetOnPath), + 0.0, + 0.0 + ) + } else if (timeAtStep > plannedMaxTimeAtStep) { + // Train passes through planned timing data after it is available: + // block is forever unavailable + return AvailabilityProperties( + Double.POSITIVE_INFINITY, + incrementalPath.toTravelledPath(stepOffsetOnPath), + 0.0, + 0.0 + ) + } + // Planned timing data respected + return AvailabilityProperties( + 0.0, + Offset(0.meters), + plannedMaxTimeAtStep - timeAtStep, + plannedMinTimeAtStep + ) + } + } + } + } + return AvailabilityProperties( + 0.0, + Offset(0.meters), + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY + ) + } + + /** Check the conflicts on the given path and return the corresponding availability. */ + private fun getConflictAvailability( + infraExplorer: InfraExplorerWithEnvelope, + startOffset: Offset, + pathStartTime: Double, + startTime: Double, + endTime: Double ): BlockAvailabilityInterface.Availability { val needFullRequirements = startOffset < infraExplorer.getPredecessorLength() val spacingRequirements = if (needFullRequirements) infraExplorer.getFullSpacingRequirements() else infraExplorer.getSpacingRequirements() - val pathStartTime = startTime - infraExplorer.interpolateDepartureFromClamp(startOffset) - val endTime = infraExplorer.interpolateDepartureFromClamp(endOffset) + pathStartTime // Modify the spacing requirements to adjust for the start time, // and filter out the ones that are outside the relevant time range @@ -77,6 +263,39 @@ data class BlockAvailability( } } + /** + * Given 2 availabilities, return the most restrictive one: + * - Unavailable > Available + * - both are unavailable: take the one with the highest duration necessary to become available + * - both are available: take the one with the lowest duration necessary to become unavailable + */ + private fun getMostRestrictiveAvailability( + firstAvailability: BlockAvailabilityInterface.Availability, + secondAvailability: BlockAvailabilityInterface.Availability + ): BlockAvailabilityInterface.Availability { + when { + firstAvailability is BlockAvailabilityInterface.Unavailable && + secondAvailability is BlockAvailabilityInterface.Unavailable -> { + if (firstAvailability.duration >= secondAvailability.duration) + return firstAvailability + return secondAvailability + } + firstAvailability is BlockAvailabilityInterface.Available && + secondAvailability is BlockAvailabilityInterface.Available -> { + if (firstAvailability.maximumDelay <= secondAvailability.maximumDelay) + return firstAvailability + return secondAvailability + } + firstAvailability is BlockAvailabilityInterface.Unavailable && + secondAvailability is BlockAvailabilityInterface.Available -> { + return firstAvailability + } + else -> { + return secondAvailability + } + } + } + /** * Turns a time into an offset on an envelope with a binary search. Can be optimized if needed. */ @@ -100,13 +319,29 @@ data class BlockAvailability( } } +private data class AvailabilityProperties( + // If a resource is unavailable, minimum delay that should be added to the train to become + // available + val minimumDelayToBecomeAvailable: Double, + // If a resource is unavailable, offset of that resource + val firstUnavailabilityOffset: Offset, + // If everything is available, maximum delay that can be added to the train without a resource + // becoming unavailable + val maximumDelayToStayAvailable: Double, + // If everything is available, minimum begin time of the next resource that could become + // unavailable + val timeOfNextUnavailability: Double +) + fun makeBlockAvailability( infra: FullInfra, requirements: Collection, workSchedules: Collection = listOf(), + steps: List = listOf(), gridMarginBeforeTrain: Double = 0.0, gridMarginAfterTrain: Double = 0.0, ): BlockAvailabilityInterface { + // Merge work schedules into train requirements val convertedWorkSchedules = convertWorkSchedules(infra.rawInfra, workSchedules) var allRequirements = requirements + convertedWorkSchedules if (gridMarginAfterTrain != 0.0 || gridMarginBeforeTrain != 0.0) { @@ -122,9 +357,12 @@ fun makeBlockAvailability( } } val trainRequirements = listOf(TrainRequirements(0L, allRequirements, listOf())) + + // Only keep steps with planned timing data + val plannedSteps = steps.filter { it.plannedTimingData != null } return BlockAvailability( - infra, incrementalConflictDetector(trainRequirements), + plannedSteps, gridMarginBeforeTrain, gridMarginAfterTrain, ) diff --git a/core/src/test/kotlin/fr/sncf/osrd/stdcm/preprocessing/BlockAvailabilityTests.kt b/core/src/test/kotlin/fr/sncf/osrd/stdcm/preprocessing/BlockAvailabilityTests.kt index 3e8074f9908..9f5638f685c 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/stdcm/preprocessing/BlockAvailabilityTests.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/stdcm/preprocessing/BlockAvailabilityTests.kt @@ -9,6 +9,8 @@ import fr.sncf.osrd.sim_infra.api.Block import fr.sncf.osrd.sim_infra.api.BlockId import fr.sncf.osrd.sim_infra.api.DirDetectorId import fr.sncf.osrd.standalone_sim.result.ResultTrain.SpacingRequirement +import fr.sncf.osrd.stdcm.PlannedTimingData +import fr.sncf.osrd.stdcm.STDCMStep import fr.sncf.osrd.stdcm.infra_exploration.InfraExplorerWithEnvelope import fr.sncf.osrd.stdcm.infra_exploration.initInfraExplorerWithEnvelope import fr.sncf.osrd.stdcm.preprocessing.implementation.makeBlockAvailability @@ -20,6 +22,7 @@ import fr.sncf.osrd.utils.Direction import fr.sncf.osrd.utils.Helpers import fr.sncf.osrd.utils.units.Offset import fr.sncf.osrd.utils.units.meters +import fr.sncf.osrd.utils.units.seconds import kotlin.Double.Companion.POSITIVE_INFINITY import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -28,6 +31,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource class BlockAvailabilityTests { // See overlapping_routes.py for a detailed infrastructure description @@ -62,19 +67,13 @@ class BlockAvailabilityTests { val allDetectors = infra.rawInfra.detectors val detectors = listOf( - allDetectors.first { det -> - infra.rawInfra.getDetectorName(det).equals("det.center.2") - }, - allDetectors.first { det -> - infra.rawInfra.getDetectorName(det).equals("det.center.3") - }, - allDetectors.first { det -> - infra.rawInfra.getDetectorName(det).equals("det.b1.nf") - }, - allDetectors.first { det -> infra.rawInfra.getDetectorName(det).equals("bf.b1") } + allDetectors.first { det -> infra.rawInfra.getDetectorName(det) == "det.center.2" }, + allDetectors.first { det -> infra.rawInfra.getDetectorName(det) == "det.center.3" }, + allDetectors.first { det -> infra.rawInfra.getDetectorName(det) == "det.b1.nf" }, + allDetectors.first { det -> infra.rawInfra.getDetectorName(det) == "bf.b1" } ) val firstDetector = - allDetectors.first { det -> infra.rawInfra.getDetectorName(det).equals("det.a1.nf") } + allDetectors.first { det -> infra.rawInfra.getDetectorName(det) == "det.a1.nf" } blocks = mutableListOf( infra.blockInfra @@ -508,6 +507,7 @@ class BlockAvailabilityTests { SpacingRequirement(zoneNames[0], startSecondConflict, POSITIVE_INFINITY, true), ), listOf(), + listOf(), marginBefore, marginAfter ) @@ -636,4 +636,316 @@ class BlockAvailabilityTests { ) as BlockAvailabilityInterface.Unavailable assertEquals(120.0, res.duration) } + + /** Train goes through planned step at the right moment: should return available. */ + @Test + fun testPlannedStepRespected() { + val explorer = makeExplorer(5, 1) + val plannedStepOffset = Offset(0.meters) + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, timeAtZoneEnd.seconds) + ) + ) + val availability = makeBlockAvailability(infra, listOf(), listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Available + assertEquals(timeAtZoneEnd.seconds.seconds, res.maximumDelay) + } + + @Test + fun testPlannedStepNotBetweenAvailabilityOffsets() { + val explorer = makeExplorer(5, 1) + val outOfBoundsStepOffset = Offset(100.meters) + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], outOfBoundsStepOffset)), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, 0.seconds) + ), + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[1], Offset(0.meters))), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, 0.seconds) + ) + ) + + val availability = makeBlockAvailability(infra, listOf(), listOf(), steps) + val res = + availability.getAvailability(explorer, Offset(0.meters), Offset(50.meters), 0.0) + as BlockAvailabilityInterface.Available + // Availability depends on which availability has the lowest maximum delay + assertEquals(POSITIVE_INFINITY, res.maximumDelay) + assertEquals(POSITIVE_INFINITY, res.timeOfNextConflict) + } + + /** + * Train goes through planned step and conflict at the right moment: should return available. + */ + @ParameterizedTest + @CsvSource("30.0", "60.0") + fun testAvailablePlannedStepBeforeAvailableConflict(conflictMaximumDelay: Double) { + val explorer = makeExplorer(5, 1) + + val plannedStepOffset = Offset(100.meters) + val timeAtStep = explorer.interpolateDepartureFromClamp(Offset(plannedStepOffset.distance)) + val stepMaximumTolerance = 60.0 + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData( + 30.seconds, + (stepMaximumTolerance / 2).seconds, + (stepMaximumTolerance / 2).seconds + ) + ) + ) + val lastAvailableTime = (stepMaximumTolerance / 2) + 30.0 + val stepMaximumDelay = lastAvailableTime - timeAtStep + + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val requirements = + listOf( + SpacingRequirement( + zoneNames[0], + timeAtZoneEnd + conflictMaximumDelay, + timeAtZoneEnd + conflictMaximumDelay * 2, + true + ) + ) + + val availability = makeBlockAvailability(infra, requirements, listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Available + // Availability depends on which availability has the lowest maximum delay + if (stepMaximumDelay < conflictMaximumDelay) + assertEquals(stepMaximumDelay, res.maximumDelay) + else assertEquals(conflictMaximumDelay, res.maximumDelay) + } + + /** + * Train passes after planned step: should have priority over available conflicts: return + * Unavailable(infinity, stepOffset). + */ + @Test + fun testPassingAfterPlannedStepWithAvailableConflict() { + val explorer = makeExplorer(5, 1) + val plannedStepOffset = Offset(100.meters) + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, 0.seconds) + ) + ) + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val requirements = + listOf( + // Requirement starting way after + SpacingRequirement(zoneNames[0], timeAtZoneEnd * 2, timeAtZoneEnd * 3, true), + ) + val availability = makeBlockAvailability(infra, requirements, listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Unavailable + // Result should be step data failing => infinity + step offset + assertEquals(POSITIVE_INFINITY, res.duration) + assertEquals(plannedStepOffset.distance, res.firstConflictOffset.distance) + } + + /** + * Train passes after planned step: should have priority over unavailable conflicts: return + * Unavailable(infinity, stepOffset). + */ + @Test + fun testPassingAfterPlannedStepWithUnavailableConflict() { + val explorer = makeExplorer(5, 1) + val plannedStepOffset = Offset(100.meters) + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, 0.seconds) + ) + ) + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val requirements = + listOf( + // Requirement starting at the same moment as well + SpacingRequirement(zoneNames[0], 0.0, timeAtZoneEnd, true), + ) + val availability = makeBlockAvailability(infra, requirements, listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Unavailable + // Step data unavailability should have priority, since its duration is higher => infinity + + // step offset + assertEquals(POSITIVE_INFINITY, res.duration) + assertEquals(plannedStepOffset.distance, res.firstConflictOffset.distance) + } + + /** + * Passing before planned step should return unavailable with the delay needed to get into the + * step. + */ + @Test + fun testPassingBeforePlannedStep() { + val explorer = makeExplorer(5, 1) + val plannedStepOffset = Offset(0.meters) + val minDelay = 60.0 + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData(minDelay.seconds, 0.seconds, 10.seconds) + ) + ) + val availability = makeBlockAvailability(infra, listOf(), listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Unavailable + assertEquals(minDelay, res.duration) + assertEquals(plannedStepOffset.distance, res.firstConflictOffset.distance) + } + + /** + * Passing before step returns delay needed to respect its timing data. This delay makes the + * train pass after another step: should return Unavailable(infinity, ...). + */ + @Test + fun testPassingBeforeStepCreatesDelayMakesAvailableStepUnavailable() { + val explorer = makeExplorer(5, 1) + val plannedStepOffset = Offset(0.meters) + val secondPlannedStepOffset = Offset(100.meters) + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val steps = + listOf( + // We need to add delay = timeAtZoneEnd + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], plannedStepOffset)), + null, + false, + PlannedTimingData(timeAtZoneEnd.seconds, 0.seconds, 0.seconds) + ), + // If we add delay = timeAtZoneEnd, this step is not respected + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], secondPlannedStepOffset)), + null, + false, + PlannedTimingData(0.seconds, 0.seconds, timeAtZoneEnd.seconds) + ) + ) + val availability = makeBlockAvailability(infra, listOf(), listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Unavailable + // The delay added to solve the first step makes the train pass after the second step => + // infinity + second step offset + assertEquals(POSITIVE_INFINITY, res.duration) + assertEquals(plannedStepOffset.distance, res.firstConflictOffset.distance) + } + + /** + * Passing before step creates unavailability. Spacing requirement creates unavailability + * afterward. If both can be resolved, return conflict unavailability, else step unavailability + * for infinity. + */ + @ParameterizedTest + @CsvSource("5.0", "30.0") + fun testPassingBeforePlannedStepThenInBlockingConflict(stepAvailableDuration: Double) { + val explorer = makeExplorer(5, 1) + + val plannedStepOffset = Offset(100.meters) + val timeAtStep = explorer.interpolateDepartureFromClamp(Offset(plannedStepOffset.distance)) + val timeAtZoneEnd = explorer.interpolateDepartureFromClamp(explorer.getSimulatedLength()) + val steps = + listOf( + STDCMStep( + listOf(PathfindingEdgeLocationId(blocks[0], Offset(100.meters))), + null, + false, + PlannedTimingData( + timeAtZoneEnd.seconds, + 0.seconds, + stepAvailableDuration.seconds + ) + ) + ) + val stepMinDelay = timeAtZoneEnd.seconds.seconds - timeAtStep + + val conflictMinDelay = 10.0 + val requirements = + listOf( + SpacingRequirement( + zoneNames[0], + stepMinDelay, + stepMinDelay + conflictMinDelay, + true + ) + ) + + val availability = makeBlockAvailability(infra, requirements, listOf(), steps) + val res = + availability.getAvailability( + explorer, + Offset(0.meters), + explorer.getSimulatedLength(), + 0.0 + ) as BlockAvailabilityInterface.Unavailable + if (stepAvailableDuration > conflictMinDelay) { + // Conflict data availability should have prio, as the conflict unavailability duration + // lasts longer, and + // because the zone itself becomes available again after 10s, which leaves 20s to + // respect step timing data + assertEquals(stepMinDelay + conflictMinDelay, res.duration) + assertTrue(res.firstConflictOffset.distance > plannedStepOffset.distance) + } else { + // The zone becomes available too late, step timing data is not respected + assertEquals(POSITIVE_INFINITY, res.duration) + assertEquals(plannedStepOffset.distance, res.firstConflictOffset.distance) + } + } }