From a2c9ef387f13ba28239c4e13257f78a15c2afad0 Mon Sep 17 00:00:00 2001 From: Eloi Charpentier Date: Fri, 29 May 2026 15:56:47 +0200 Subject: [PATCH 1/2] do-not-merge-without-careful-review! llm generated partial fix This does fix several mismatch cases, but it's not always enough. The main issue it's trying to fix is some (likely) bug in the way we estimate the timed points when it's covered by several engineering allowance ranges. --- .../stdcm/graph/PostProcessingSimulation.kt | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) 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..b68453e0aab 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 @@ -334,17 +334,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. From e4bd911bbcae520ff66468219ae0a6258ba86923 Mon Sep 17 00:00:00 2001 From: Eloi Charpentier Date: Fri, 29 May 2026 16:32:08 +0200 Subject: [PATCH 2/2] core: wip: improve error logging on mismatch Signed-off-by: Eloi Charpentier --- .../stdcm/graph/PostProcessingSimulation.kt | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) 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 b68453e0aab..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 = @@ -440,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( @@ -456,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 */ @@ -576,6 +576,7 @@ private fun handlePostProcessingConflict( isMareco: Boolean, allowanceRanges: List, attempt: Int, + conflictCauses: List, ): FinalEnvelopeResult { if (graph.searchMetadata != null) { val stringDebugData by lazy { @@ -604,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()) {