diff --git a/core/src/main/kotlin/fr/sncf/osrd/stdcm/graph/PostProcessingSimulation.kt b/core/src/main/kotlin/fr/sncf/osrd/stdcm/graph/PostProcessingSimulation.kt index 9fac41632d5..61d625d8b3c 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/stdcm/graph/PostProcessingSimulation.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/stdcm/graph/PostProcessingSimulation.kt @@ -134,8 +134,9 @@ fun buildFinalEnvelope( comfort, ) ) + val conflict = findConflict(newEnvelope, blockAvailability, edges, updatedTimeData) val conflictOffset = - findConflictOffsets(newEnvelope, blockAvailability, edges, updatedTimeData) + conflict?.firstConflictOffset ?: return FinalEnvelopeResult(newEnvelope, allowanceRanges) if (fixedPoints.any { it.offset == conflictOffset }) { // Error case: a conflict prevents us from finding a solution, @@ -160,6 +161,7 @@ fun buildFinalEnvelope( isMareco, allowanceRanges, attempt, + conflict.causes, ) } val newPoint = @@ -334,17 +336,12 @@ private fun makeFixedPoint( offset = Offset.min(offset, pathLength) var time = getTimeOnEdges(edges, offset, updatedTimeData) - // Points located on engineering allowances are tricky to get right: we know we need to add some - // time compared to the reference time, but we don't know how much. The standalone sim can't - // take "valid intervals", just scheduled points. - // This is a "best effort" guess, were we assume that the allowance is mostly distributed - // linearly. TODO: find a more robust algorithm. - val currentAllowances = allowanceRanges.filter { conflictOffset in it.from.. 0) stopDuration else null) } +/** + * Estimates the time at an offset located inside one or more engineering allowance ranges. + * + * Each range ends at its carrier edge's start, whose exploration time already includes the absorbed + * delay; those per-boundary times are monotonic in offset. We collect all range boundaries, attach + * each one's exploration time (forced non-decreasing as a safety net), then linearly interpolate + * the requested offset between its bracketing boundaries. This replaces summing each containing + * range's linear ramp, which double-counted delay on overlapping/nested ranges. + */ +private fun interpolateAllowanceTime( + edges: List, + offset: Offset, + updatedTimeData: TimeData, + allowanceRanges: List, +): Double { + val boundaries = sortedSetOf>() + for (range in allowanceRanges) { + boundaries.add(range.from) + boundaries.add(range.to) + } + val anchors = mutableListOf, Double>>() + var prevTime = Double.NEGATIVE_INFINITY + for (b in boundaries) { + val t = max(prevTime, getTimeOnEdges(edges, b, updatedTimeData)) + anchors.add(b to t) + prevTime = t + } + val hiIndex = anchors.indexOfFirst { it.first >= offset } + if (hiIndex < 0) return anchors.last().second + if (hiIndex == 0) return anchors.first().second + val (loOffset, loTime) = anchors[hiIndex - 1] + val (hiOffset, hiTime) = anchors[hiIndex] + if (hiOffset == loOffset) return hiTime + val ratio = (offset - loOffset) / (hiOffset - loOffset) + return loTime + (hiTime - loTime) * ratio +} + /** * Rounds the given offset to an edge transition. If `roundToEnd` is set, rounds to the end of the * edge containing the offset. Otherwise, rounds to the start. @@ -408,14 +442,15 @@ private fun getTimeOnEdges( /** * Looks for the first detected conflict that would happen on the given envelope. If a conflict is - * found, returns its offset. Otherwise, returns null. + * found, returns an Unavailable instance, containing an offset and some metadata. Otherwise, + * returns null. */ -private fun findConflictOffsets( +private fun findConflict( envelope: Envelope, blockAvailability: BlockAvailabilityInterface, edges: List, updatedTimeData: TimeData, -): Offset? { +): BlockAvailabilityInterface.Unavailable? { val explorer = getUpdatedExplorer(edges, envelope, updatedTimeData) val availability = blockAvailability.getAvailability( @@ -424,10 +459,7 @@ private fun findConflictOffsets( explorer.getSimulatedLength(), updatedTimeData.departureTime, ) - val offsetDistance = - (availability as? BlockAvailabilityInterface.Unavailable)?.firstConflictOffset - ?: return null - return offsetDistance + return availability as? BlockAvailabilityInterface.Unavailable } /** Returns an infra explorer with envelope, with the given new envelope and updated time data */ @@ -544,6 +576,7 @@ private fun handlePostProcessingConflict( isMareco: Boolean, allowanceRanges: List, attempt: Int, + conflictCauses: List, ): FinalEnvelopeResult { if (graph.searchMetadata != null) { val stringDebugData by lazy { @@ -572,11 +605,34 @@ private fun handlePostProcessingConflict( postProcessingLogger.error( "NOTE: look through the logs for allowance issues, they may cause mismatches." ) + postProcessingLogger.error( + "NOTE: set STDCM_DEBUG_DATA_FILENAME or look at the s3 files to get search data, " + + "which can be handled by core/script/generate-debug-space-chart.py " + + "even with no successful simulation." + ) + + fun formatTime(timeSinceDeparture: Double): String? { + val originalTime = graph.searchMetadata?.originalStartTime ?: return null + // Convert to Europe/Paris to match generate-debug-space-chart.py + val absoluteTime = + originalTime + .plusSeconds((timeSinceDeparture + updatedTimeData.departureTime).toLong()) + .withZoneSameInstant(java.time.ZoneId.of("Europe/Paris")) + return "%02d:%02d:%02d".format(absoluteTime.hour, absoluteTime.minute, absoluteTime.second) + } + val conflictTime = fixedPoints.first { it.offset == conflictOffset }.time postProcessingLogger.info( " conflict happened at offset=$conflictOffset/${maxSpeedEnvelope.endPos.toInt()} " + "and t=${conflictTime.toInt()}/${updatedTimeData.timeSinceDeparture.toInt()}" ) + postProcessingLogger.info(" absolute conflict time: ${formatTime(conflictTime)}") + postProcessingLogger.info(" conflict causes:") + for (cause in conflictCauses) { + postProcessingLogger.info( + " id \"${cause.cause}\", would need to arrive ${cause.duration}s later (or leave earlier)" + ) + } var remainingDistance = conflictOffset.distance for ((i, edge) in edges.withIndex()) {