diff --git a/core/src/main/kotlin/fr/sncf/osrd/standalone_sim/StandaloneSimulation.kt b/core/src/main/kotlin/fr/sncf/osrd/standalone_sim/StandaloneSimulation.kt index 5a3466289e6..5dc6877878a 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/standalone_sim/StandaloneSimulation.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/standalone_sim/StandaloneSimulation.kt @@ -191,7 +191,9 @@ fun makeMRSPResponse(speedLimits: Envelope): MRSPResponse { /** * Build the final envelope from the max effort / provisional envelopes. The final envelope modifies - * the margin ranges to match the scheduled points. + * the margin ranges to match the scheduled points. The added time is distributed over the different + * margin ranges, following a logic described in details on the OSRD website: + * https://osrd.fr/en/docs/reference/design-docs/timetable/#combining-margins-and-schedule */ fun buildFinalEnvelope( maxEffortEnvelope: Envelope, @@ -204,6 +206,9 @@ fun buildFinalEnvelope( fun getEnvelopeTimeAt(offset: Offset): Double { return provisionalEnvelope.interpolateDepartureFromClamp(offset.distance.meters) } + fun getMaxEffortEnvelopeTimeAt(offset: Offset): Double { + return maxEffortEnvelope.interpolateDepartureFromClamp(offset.distance.meters) + } var prevFixedPointOffset = Offset(0.meters) var prevFixedPointDepartureTime = 0.0 val marginRanges = mutableListOf() @@ -217,24 +222,51 @@ fun buildFinalEnvelope( val sectionTime = getEnvelopeTimeAt(point.pathOffset) - getEnvelopeTimeAt(prevFixedPointOffset) val arrivalTime = prevFixedPointDepartureTime + sectionTime - var extraTime = point.arrival.seconds - arrivalTime - if (extraTime < 0.0) { - // TODO: raise a warning - standaloneSimLogger.warn("impossible scheduled point") - extraTime = 0.0 - } - marginRanges.addAll( - distributeAllowance( - maxEffortEnvelope, - provisionalEnvelope, - extraTime, - margins, - prevFixedPointOffset, - point.pathOffset + val extraTime = point.arrival.seconds - arrivalTime + if (extraTime >= 0.0) { + marginRanges.addAll( + distributeAllowance( + maxEffortEnvelope, + provisionalEnvelope, + extraTime, + margins, + prevFixedPointOffset, + point.pathOffset + ) ) - ) + prevFixedPointDepartureTime = arrivalTime + extraTime + (point.stopFor?.seconds ?: 0.0) + } else { + // We need to *remove* time compared to the provisional envelope. + // Ideally we would distribute the (negative) extra time following the same logic as + // when it's positive. But this is tricky: as we get closer to max effort envelope (hard + // limit), we need to redistribute the time in some cases. + // We currently handle this by ignoring the distribution over different margin ranges, + // we just set the time for the scheduled point without more details. It will be easier + // to handle it properly when we'll have migrated to standalone sim v3. + val maxEffortSectionTime = + getMaxEffortEnvelopeTimeAt(point.pathOffset) - + getMaxEffortEnvelopeTimeAt(prevFixedPointOffset) + val earliestPossibleArrival = prevFixedPointDepartureTime + maxEffortSectionTime + var maxEffortExtraTime = point.arrival.seconds - earliestPossibleArrival + if (maxEffortExtraTime < 0.0) { + standaloneSimLogger.warn("impossible scheduled point") + // TODO: raise warning: scheduled point isn't possible + maxEffortExtraTime = 0.0 + } else { + standaloneSimLogger.warn("scheduled point doesn't follow standard allowance") + // TODO: raise warning: scheduled point doesn't follow standard allowance + } + marginRanges.add( + AllowanceRange( + prevFixedPointOffset.distance.meters, + point.pathOffset.distance.meters, + FixedTime(maxEffortExtraTime) + ) + ) + prevFixedPointDepartureTime = + earliestPossibleArrival + maxEffortExtraTime + (point.stopFor?.seconds ?: 0.0) + } prevFixedPointOffset = point.pathOffset - prevFixedPointDepartureTime = arrivalTime + extraTime + (point.stopFor?.seconds ?: 0.0) } val pathEnd = Offset(maxEffortEnvelope.endPos.meters) if (prevFixedPointOffset < pathEnd) { diff --git a/tests/tests/test_timetable_v2.py b/tests/tests/test_timetable_v2.py index 6409e875dc5..0025e14252e 100644 --- a/tests/tests/test_timetable_v2.py +++ b/tests/tests/test_timetable_v2.py @@ -93,3 +93,52 @@ def test_conflicts( assert response.status_code == 200 actual_conflicts = {conflict["conflict_type"] for conflict in response.json()} assert actual_conflicts == expected_conflict_types + + +def test_scheduled_points_with_incompatible_margins( + small_infra: Infra, + timetable_v2: TimetableV2, + fast_rolling_stock: int, +): + requests.post(f"{EDITOAST_URL}infra/{small_infra.id}/load").raise_for_status() + train_schedule_payload = [ + { + "comfort": "STANDARD", + "constraint_distribution": "STANDARD", + "initial_speed": 0, + "labels": [], + "options": {"use_electrical_profiles": False}, + "path": [ + {"id": "start", "track": "TC0", "offset": 185000}, + {"id": "end", "track": "TD0", "offset": 24820000}, + ], + "power_restrictions": [], + "rolling_stock_name": "fast_rolling_stock", + "schedule": [ + { + "at": "start", + }, + { + "at": "end", + "arrival": "PT4000S", + }, + ], + "margins": {"boundaries": [], "values": ["100%"]}, + "speed_limit_tag": "MA100", + "start_time": "2024-05-22T08:00:00.000Z", + "train_name": "name", + } + ] + response = requests.post( + f"{EDITOAST_URL}/v2/timetable/{timetable_v2.id}/train_schedule", json=train_schedule_payload + ) + response.raise_for_status() + train_id = response.json()[0]["id"] + response = requests.get(f"{EDITOAST_URL}/v2/train_schedule/{train_id}/simulation/?infra_id={small_infra.id}") + response.raise_for_status() + content = response.json() + sim_output = content["final_output"] + travel_time_seconds = sim_output["times"][-1] / 1_000 + + # Should arrive roughly 4000s after departure, even if that doesn't fit the margins + assert abs(travel_time_seconds - 4_000) < 2