diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlockRequest.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlockRequest.kt index fdee1e777be..37775959814 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlockRequest.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlockRequest.kt @@ -17,6 +17,7 @@ class PathfindingBlockRequest( val rollingStockSupportedSignalingSystems: List, @Json(name = "rolling_stock_maximum_speed") val rollingStockMaximumSpeed: Double, @Json(name = "rolling_stock_length") val rollingStockLength: Double, + @Json(name = "stop_at_next_signal") val stopAtNextSignal: Boolean, val timeout: Double?, val infra: String, @Json(name = "expected_version") val expectedVersion: String, diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlocksEndpointV2.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlocksEndpointV2.kt index e8ff1d325c3..ca2428bca6f 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlocksEndpointV2.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/pathfinding/PathfindingBlocksEndpointV2.kt @@ -80,10 +80,25 @@ fun runPathfinding( ): PathfindingBlockResponse { // Parse the waypoints val waypoints = ArrayList>>() - for (step in request.pathItems) { + request.pathItems.forEachIndexed { stepIndex, step -> val allStarts = HashSet>() for (direction in Direction.entries) { - for (waypoint in step) allStarts.addAll(findWaypointBlocks(infra, waypoint, direction)) + for (waypoint in step) { + val waypointBlocks = findWaypointBlocks(infra, waypoint, direction) + if (request.stopAtNextSignal && stepIndex != 0) { + allStarts.addAll( + waypointBlocks.map { + findNextSignalBlockOnWaypointBlock( + it, + infra, + request.rollingStockLength + ) + } + ) + } else { + allStarts.addAll(waypointBlocks) + } + } } waypoints.add(allStarts) } @@ -369,3 +384,37 @@ private fun getBlockOffset( String.format("getBlockOffset: Track chunk %s not in block %s", trackChunkId, blockId) ) } + +fun findNextSignalBlockOnWaypointBlock( + waypointBlock: PathfindingEdgeLocationId, + infra: FullInfra, + rollingStockLength: Double +): PathfindingEdgeLocationId { + val nextSignalOffset = + getNextSignalOffset(waypointBlock.edge, waypointBlock.offset, infra, rollingStockLength) + return PathfindingEdgeLocationId(waypointBlock.edge, nextSignalOffset) +} + +private fun getNextSignalOffset( + blockId: BlockId, + blockOffset: Offset, + infra: FullInfra, + rollingStockLength: Double +): Offset { + val signalsPositions = infra.blockInfra.getSignalsPositions(blockId) + val blockLength = infra.blockInfra.getBlockLength(blockId).distance + val nextSignalPosition = signalsPositions.firstOrNull { it.distance >= blockOffset.distance } + + // some blocks are < 1m long (even 0m), we can't get further in the block + val maxHeadOffset = + if (blockOffset.distance < 1.meters) { + blockOffset.distance + } else { + (nextSignalPosition?.distance ?: blockLength) - 1.meters + } + + val minTailOffset = blockOffset.distance + rollingStockLength.meters + val finalOffset = if (minTailOffset <= maxHeadOffset) minTailOffset else maxHeadOffset + + return Offset(finalOffset) +} diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/standalone_sim/SimulationRequest.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/standalone_sim/SimulationRequest.kt index ba1785ded01..db5b2a1dbe0 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/standalone_sim/SimulationRequest.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/standalone_sim/SimulationRequest.kt @@ -141,5 +141,5 @@ class SimulationPowerRestrictionItem( ) class TrainScheduleOptions( - @Json(name = "use_electrical_profiles") val useElectricalProfiles: Boolean + @Json(name = "use_electrical_profiles") val useElectricalProfiles: Boolean, ) 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 bc2f53e9507..82fb9640eaa 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 @@ -5,6 +5,7 @@ import fr.sncf.osrd.api.ExceptionHandler import fr.sncf.osrd.api.FullInfra import fr.sncf.osrd.api.InfraManager import fr.sncf.osrd.api.api_v2.* +import fr.sncf.osrd.api.api_v2.pathfinding.findNextSignalBlockOnWaypointBlock import fr.sncf.osrd.api.api_v2.pathfinding.findWaypointBlocks import fr.sncf.osrd.api.api_v2.pathfinding.runPathfindingBlockPostProcessing import fr.sncf.osrd.api.api_v2.standalone_sim.* @@ -97,7 +98,7 @@ class STDCMEndpointV2(private val infraManager: InfraManager) : Take { convertWorkScheduleCollection(infra.rawInfra, request.workSchedules) trainsRequirements.add(convertedWorkSchedules) val spacingRequirements = trainsRequirements.flatMap { it.spacingRequirements } - val steps = parseSteps(infra, request.pathItems, request.startTime) + val steps = parseSteps(infra, request.pathItems, request.startTime, rollingStock.length) // Run the STDCM pathfinding val path = @@ -281,7 +282,8 @@ fun buildTemporarySpeedLimitManager( private fun parseSteps( infra: FullInfra, pathItems: List, - startTime: ZonedDateTime + startTime: ZonedDateTime, + rollingStockLength: Double ): List { if (pathItems.last().stopDuration == null) { throw OSRDError(ErrorType.MissingLastSTDCMStop) @@ -296,9 +298,15 @@ private fun parseSteps( pathItems.first().stopDuration = null return pathItems - .map { + .mapIndexed { index, it -> STDCMStep( - findWaypointBlocks(infra, it.locations), + if (index != 0) { + findWaypointBlocks(infra, it.locations).map { waypointBlock -> + findNextSignalBlockOnWaypointBlock(waypointBlock, infra, rollingStockLength) + } + } else { + findWaypointBlocks(infra, it.locations) + }, it.stopDuration?.seconds, it.stopDuration != null, if (it.stepTimingData != null) @@ -363,7 +371,7 @@ private fun checkForConflicts( private fun findWaypointBlocks( infra: FullInfra, - waypoints: Collection + waypoints: Collection, ): Set> { val waypointBlocks = HashSet>() for (waypoint in waypoints) { diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMRequestV2.kt b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMRequestV2.kt index d4cbb90cbc9..c382e725fcc 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMRequestV2.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/api_v2/stdcm/STDCMRequestV2.kt @@ -34,6 +34,7 @@ class STDCMRequestV2( @Json(name = "rolling_stock_supported_signaling_systems") val rollingStockSupportedSignalingSystems: List, @Json(name = "trains_requirements") val trainsRequirements: Map, + @Json(name = "stop_at_next_signal") val stopAtNextSignal: Boolean, // Simulation inputs val comfort: Comfort, diff --git a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathfindingV2Test.kt b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathfindingV2Test.kt index a7bd11013e8..6adea531f8e 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathfindingV2Test.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathfindingV2Test.kt @@ -36,6 +36,7 @@ class PathfindingV2Test : ApiTest() { infra = "tiny_infra/infra.json", expectedVersion = "1", pathItems = waypoints, + stopAtNextSignal = false, ) ) val rawResponse = @@ -96,6 +97,7 @@ class PathfindingV2Test : ApiTest() { infra = "tiny_infra/infra.json", expectedVersion = "1", pathItems = waypoints, + stopAtNextSignal = false, ) ) val unconstrainedRawResponse = @@ -119,6 +121,7 @@ class PathfindingV2Test : ApiTest() { infra = "tiny_infra/infra.json", expectedVersion = "1", pathItems = waypoints, + stopAtNextSignal = false, ) ) val rawResponse = @@ -167,6 +170,7 @@ class PathfindingV2Test : ApiTest() { infra = "small_infra/infra.json", expectedVersion = "1", pathItems = waypoints, + stopAtNextSignal = false, ) ) val unconstrainedRawResponse = @@ -190,6 +194,7 @@ class PathfindingV2Test : ApiTest() { infra = "small_infra/infra.json", expectedVersion = "1", pathItems = waypoints, + stopAtNextSignal = false, ) ) val rawResponse = @@ -239,4 +244,104 @@ class PathfindingV2Test : ApiTest() { ) ) } + + @Test + fun stopAtNextSignalTest() { + val waypointStart = TrackLocation("TA1", Offset(500.meters)) + val waypointIntermediate = TrackLocation("TC1", Offset(550.meters)) + val waypointEnd = TrackLocation("TD0", Offset(14000.meters)) + val waypointsStart = listOf(waypointStart) + val waypointsIntermediate = listOf(waypointIntermediate) + val waypointsEnd = listOf(waypointEnd) + val waypoints = listOf(waypointsStart, waypointsIntermediate, waypointsEnd) + + val nonStopAtNextSignalRequestBody = + pathfindingRequestAdapter.toJson( + PathfindingBlockRequest( + rollingStockLoadingGauge = RJSLoadingGaugeType.G1, + rollingStockIsThermal = true, + rollingStockSupportedElectrifications = listOf(), + rollingStockSupportedSignalingSystems = listOf("BAL"), + rollingStockMaximumSpeed = 320.0, + rollingStockLength = 0.0, + timeout = null, + infra = "small_infra/infra.json", + expectedVersion = "1", + pathItems = waypoints, + stopAtNextSignal = true, + ) + ) + + val shortTrainRequestBody = + pathfindingRequestAdapter.toJson( + PathfindingBlockRequest( + rollingStockLoadingGauge = RJSLoadingGaugeType.G1, + rollingStockIsThermal = true, + rollingStockSupportedElectrifications = listOf(), + rollingStockSupportedSignalingSystems = listOf("BAL"), + rollingStockMaximumSpeed = 320.0, + rollingStockLength = 10.0, + timeout = null, + infra = "small_infra/infra.json", + expectedVersion = "1", + pathItems = waypoints, + stopAtNextSignal = true, + ) + ) + + val longTrainRequestBody = + pathfindingRequestAdapter.toJson( + PathfindingBlockRequest( + rollingStockLoadingGauge = RJSLoadingGaugeType.G1, + rollingStockIsThermal = true, + rollingStockSupportedElectrifications = listOf(), + rollingStockSupportedSignalingSystems = listOf("BAL"), + rollingStockMaximumSpeed = 320.0, + rollingStockLength = 200.0, + timeout = null, + infra = "small_infra/infra.json", + expectedVersion = "1", + pathItems = waypoints, + stopAtNextSignal = true, + ) + ) + + val nonStopAtNextSignalRawResponse = + PathfindingBlocksEndpointV2(infraManager) + .act(RqFake("POST", "/v2/pathfinding/blocks", nonStopAtNextSignalRequestBody)) + val nonStopAtNextSignalResponse = + TakesUtils.readBodyResponse(nonStopAtNextSignalRawResponse) + val nonStopAtNextSignalParsed = + (pathfindingResponseAdapter.fromJson(nonStopAtNextSignalResponse) + as? PathfindingBlockSuccess)!! + + val shortTrainRawResponse = + PathfindingBlocksEndpointV2(infraManager) + .act(RqFake("POST", "/v2/pathfinding/blocks", shortTrainRequestBody)) + val shortTrainResponse = TakesUtils.readBodyResponse(shortTrainRawResponse) + val shortTrainParsed = + (pathfindingResponseAdapter.fromJson(shortTrainResponse) as? PathfindingBlockSuccess)!! + + val longTrainRawResponse = + PathfindingBlocksEndpointV2(infraManager) + .act(RqFake("POST", "/v2/pathfinding/blocks", longTrainRequestBody)) + val longTrainResponse = TakesUtils.readBodyResponse(longTrainRawResponse) + val longTrainParsed = + (pathfindingResponseAdapter.fromJson(longTrainResponse) as? PathfindingBlockSuccess)!! + + assertEquals(3, nonStopAtNextSignalParsed.pathItemPositions.size) + assertEquals(0.meters, nonStopAtNextSignalParsed.pathItemPositions[0].distance) + assertEquals(12050.meters, nonStopAtNextSignalParsed.pathItemPositions[1].distance) + assertEquals(26500.meters, nonStopAtNextSignalParsed.pathItemPositions[2].distance) + + assertEquals(3, shortTrainParsed.pathItemPositions.size) + assertEquals(0.meters, shortTrainParsed.pathItemPositions[0].distance) + assertEquals(12060.meters, shortTrainParsed.pathItemPositions[1].distance) + assertEquals(26510.meters, shortTrainParsed.pathItemPositions[2].distance) + + assertEquals(3, longTrainParsed.pathItemPositions.size) + assertEquals(0.meters, longTrainParsed.pathItemPositions[0].distance) + assertEquals(12250.meters, longTrainParsed.pathItemPositions[1].distance) + assertEquals(26516.5.meters, longTrainParsed.pathItemPositions[2].distance) + } } diff --git a/editoast/editoast_schemas/src/train_schedule/train_schedule_options.rs b/editoast/editoast_schemas/src/train_schedule/train_schedule_options.rs index 693553e87b0..961edd24337 100644 --- a/editoast/editoast_schemas/src/train_schedule/train_schedule_options.rs +++ b/editoast/editoast_schemas/src/train_schedule/train_schedule_options.rs @@ -14,8 +14,22 @@ pub struct TrainScheduleOptions { #[derivative(Default(value = "true"))] #[serde(default = "default_use_electrical_profiles")] use_electrical_profiles: bool, + + #[derivative(Default(value = "true"))] + #[serde(default = "default_stop_at_next_signal")] + stop_at_next_signal: bool, } fn default_use_electrical_profiles() -> bool { true } + +fn default_stop_at_next_signal() -> bool { + true +} + +impl TrainScheduleOptions { + pub fn stops_at_next_signal(&self) -> bool { + self.stop_at_next_signal + } +} diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index df21a9065db..d68ef84c8cb 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -8129,6 +8129,7 @@ components: - path_items - rolling_stock_maximum_speed - rolling_stock_length + - stop_at_next_signal properties: path_items: type: array @@ -8160,6 +8161,9 @@ components: items: type: string description: List of supported signaling systems + stop_at_next_signal: + type: boolean + description: Stops the train at next signal instead of on path item PathfindingInputError: oneOf: - type: object @@ -11346,6 +11350,8 @@ components: options: type: object properties: + stop_at_next_signal: + type: boolean use_electrical_profiles: type: boolean additionalProperties: false @@ -11441,6 +11447,8 @@ components: TrainScheduleOptions: type: object properties: + stop_at_next_signal: + type: boolean use_electrical_profiles: type: boolean additionalProperties: false diff --git a/editoast/src/core/pathfinding.rs b/editoast/src/core/pathfinding.rs index 74c5ba738fd..5dca7b97018 100644 --- a/editoast/src/core/pathfinding.rs +++ b/editoast/src/core/pathfinding.rs @@ -42,6 +42,8 @@ pub struct PathfindingRequest { pub rolling_stock_maximum_speed: f64, /// Rolling stock length in meters: pub rolling_stock_length: f64, + /// If the train should stop on the next signal instead of on the operational point + pub stop_at_next_signal: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)] diff --git a/editoast/src/views/path/pathfinding.rs b/editoast/src/views/path/pathfinding.rs index ddef64d7edc..09e55341fda 100644 --- a/editoast/src/views/path/pathfinding.rs +++ b/editoast/src/views/path/pathfinding.rs @@ -71,6 +71,8 @@ struct PathfindingInput { /// Rolling stock length #[schema(value_type = f64)] rolling_stock_length: OrderedFloat, + /// Stops the train at next signal instead of on path item + stop_at_next_signal: bool, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, ToSchema)] @@ -325,6 +327,7 @@ fn build_pathfinding_request( .clone(), rolling_stock_maximum_speed: pathfinding_input.rolling_stock_maximum_speed.0, rolling_stock_length: pathfinding_input.rolling_stock_length.0, + stop_at_next_signal: pathfinding_input.stop_at_next_signal, }) } @@ -398,6 +401,7 @@ pub async fn pathfinding_from_train_batch( .into_iter() .map(|item| item.location) .collect(), + stop_at_next_signal: train_schedule.options.stops_at_next_signal(), }; to_compute.push(path_input); to_compute_index.push(index); diff --git a/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts b/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts index 5cae2213a28..f2cc5bf7101 100644 --- a/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts +++ b/front/src/applications/operationalStudies/hooks/useSetupItineraryForTrainUpdate.ts @@ -149,6 +149,7 @@ const useSetupItineraryForTrainUpdate = (trainIdToEdit: number) => { rolling_stock_supported_signaling_systems: rollingStock.supported_signaling_systems, rolling_stock_maximum_speed: rollingStock.max_speed, rolling_stock_length: rollingStock.length, + stop_at_next_signal: true, // TODO: change to false and set to true elsewhere if needed }, }; const pathfindingResult = await postPathfindingBlocks(params).unwrap(); diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index bbbaf449098..f5e2feaf711 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -2737,6 +2737,8 @@ export type PathfindingInput = { rolling_stock_supported_electrifications: string[]; /** List of supported signaling systems */ rolling_stock_supported_signaling_systems: string[]; + /** Stops the train at next signal instead of on path item */ + stop_at_next_signal: boolean; }; export type RoutePath = { switches_directions: (string & string)[][]; @@ -3193,6 +3195,7 @@ export type Margins = { values: string[]; }; export type TrainScheduleOptions = { + stop_at_next_signal?: boolean; use_electrical_profiles?: boolean; }; export type PathItem = PathItemLocation & { @@ -3438,6 +3441,7 @@ export type TrainScheduleBase = { values: string[]; }; options?: { + stop_at_next_signal?: boolean; use_electrical_profiles?: boolean; }; path: (PathItemLocation & { diff --git a/front/src/modules/pathfinding/utils.ts b/front/src/modules/pathfinding/utils.ts index d8b6b9e377b..f8a216624a2 100644 --- a/front/src/modules/pathfinding/utils.ts +++ b/front/src/modules/pathfinding/utils.ts @@ -79,6 +79,7 @@ export const getPathfindingQuery = ({ rolling_stock_supported_signaling_systems: rollingStock.supported_signaling_systems, rolling_stock_maximum_speed: rollingStock.max_speed, rolling_stock_length: rollingStock.length, + stop_at_next_signal: true, // TODO: change to false and set to true elsewhere if needed }, }; } diff --git a/python/osrd_schemas/osrd_schemas/train_schedule.py b/python/osrd_schemas/osrd_schemas/train_schedule.py index bccd563f95d..fe20b7c5311 100644 --- a/python/osrd_schemas/osrd_schemas/train_schedule.py +++ b/python/osrd_schemas/osrd_schemas/train_schedule.py @@ -170,12 +170,19 @@ class PowerRestrictionRanges(RootModel): class TrainScheduleOptions(BaseModel): - """Optional arguments for the standalone simulation.""" + """Optional arguments : + - `ignore_electrical_profiles` : for the standalone simulation + - `stop_at_next_signal` : for dealing with stopped trains that overflow on switches during imports + """ ignore_electrical_profiles: bool = Field( default=False, description="If true, the electrical profiles are ignored in the standalone simulation", ) + stop_at_next_signal: bool = Field( + default=False, + description="If true, the train will stop at the next signal instead of at the operational point", + ) if __name__ == "__main__":