diff --git a/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/implementations/TrainPathImpl.kt b/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/implementations/TrainPathImpl.kt index 837fe84ea82..e5437411e2d 100644 --- a/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/implementations/TrainPathImpl.kt +++ b/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/implementations/TrainPathImpl.kt @@ -324,7 +324,7 @@ data class TrainPathImpl( return distanceRangeMapOf(entries) } - private fun getTrackRanges(): List { + override fun getTrackRanges(): List { val partialTrackRanges = mutableListOf() for (chunkRange in chunks) { val dirChunkId = chunkRange.value diff --git a/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/interfaces/TrainPath.kt b/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/interfaces/TrainPath.kt index 65a2a224754..7b935a9f6a6 100644 --- a/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/interfaces/TrainPath.kt +++ b/core/kt-osrd-path/src/main/kotlin/fr/sncf/osrd/path/interfaces/TrainPath.kt @@ -57,6 +57,8 @@ interface TrainPath : PhysicsPath, PathProperties { fun getZonePaths(): List fun getZoneRanges(): List + + fun getTrackRanges(): List // To be expanded as needed with other linear objects } diff --git a/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/LineString.kt b/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/LineString.kt index 89f274289f5..1be2117f0ea 100644 --- a/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/LineString.kt +++ b/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/LineString.kt @@ -205,7 +205,7 @@ data class LineString( for (i in 0.., val zones: RangeValues, + // Geometric to topological projection (or vice-versa) using haversine formula for geometric + // offsets + @Json(name = "geom_projection") val geomProjection: GeometricProjection, ) interface Electrification @@ -54,6 +58,22 @@ data class OperationalPointPartExtension(val sncf: OperationalPointPartSncfExten data class OperationalPointPartSncfExtension(val kp: String) +data class GeometricProjection( + @Json(name = "topo_offsets") val topoOffsets: List>, + @Json(name = "geom_offsets") val geomOffsets: List>, +) { + init { + // There must be the same number of topological boundaries and geometric boundaries + // and at least two of each (the beginning and the end) + assert(topoOffsets.size == geomOffsets.size && topoOffsets.size >= 2) + // Each list must start by 0 + assert(topoOffsets[0].distance == Distance.ZERO && geomOffsets[0].distance == Distance.ZERO) + // Each list must be increasing (not strictly) + assert(topoOffsets.zipWithNext().all { it.first <= it.second }) + assert(geomOffsets.zipWithNext().all { it.first <= it.second }) + } +} + val polymorphicElectrificationAdapter: PolymorphicJsonAdapterFactory = PolymorphicJsonAdapterFactory.of(Electrification::class.java, "type") .withSubtype(Electrified::class.java, "electrification") diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponseConverter.kt b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponseConverter.kt index cc538475648..be6a12d4e94 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponseConverter.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponseConverter.kt @@ -7,11 +7,14 @@ import fr.sncf.osrd.path.interfaces.TrainPath import fr.sncf.osrd.railjson.schema.geom.RJSLineString import fr.sncf.osrd.sim_infra.api.NeutralSection import fr.sncf.osrd.sim_infra.api.RawSignalingInfra +import fr.sncf.osrd.sim_infra.api.TrackSectionId import fr.sncf.osrd.utils.DistanceRangeMap import fr.sncf.osrd.utils.DistanceRangeMapImpl import fr.sncf.osrd.utils.from import fr.sncf.osrd.utils.toRangeMap +import fr.sncf.osrd.utils.units.Length import fr.sncf.osrd.utils.units.Offset +import fr.sncf.osrd.utils.units.meters fun makePathPropResponse(pathProperties: TrainPath, rawInfra: RawSignalingInfra): PathPropResponse { return PathPropResponse( @@ -21,6 +24,7 @@ fun makePathPropResponse(pathProperties: TrainPath, rawInfra: RawSignalingInfra) makeGeographic(pathProperties), makeOperationalPoints(pathProperties, rawInfra), makeZones(pathProperties, rawInfra), + makeGeometricProjection(pathProperties, rawInfra), ) } @@ -148,3 +152,36 @@ private fun makeElectrificationMap( } return res } + +private fun makeGeometricProjection( + pathProperties: TrainPath, + rawInfra: RawSignalingInfra, +): GeometricProjection { + val trackRanges = pathProperties.getTrackRanges() + + fun getTrackSectionGeometricLength(trackSection: TrackSectionId): Length { + val chunks = rawInfra.getTrackSectionChunks(trackSection) + return chunks + .map { + Length(rawInfra.getTrackChunkGeom(it).length.meters) + } + .reduce { acc, length -> acc + length.distance } + } + + val geomOffsets = mutableListOf>(Offset.zero()) + val topoOffsets = mutableListOf>(Offset.zero()) + + trackRanges.forEachIndexed { i, range -> + val rangeTopoLength = range.length + topoOffsets.addLast(topoOffsets.last() + rangeTopoLength) + + val trackSectionTopoLength = range.objectLength + val proportion = rangeTopoLength / trackSectionTopoLength.distance + + val trackSectionGeomLength = getTrackSectionGeometricLength(range.value.value) + val rangeGeomLength = trackSectionGeomLength.distance * proportion + geomOffsets.addLast(geomOffsets.last() + rangeGeomLength) + } + + return GeometricProjection(topoOffsets, geomOffsets) +} diff --git a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt index f6ef6bbc712..6931f200f6e 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt @@ -6,6 +6,7 @@ import fr.sncf.osrd.api.RangeValues import fr.sncf.osrd.api.path_properties.* import fr.sncf.osrd.cli.RqFake import fr.sncf.osrd.railjson.schema.common.graph.EdgeDirection +import fr.sncf.osrd.utils.units.Distance import fr.sncf.osrd.utils.units.Offset import fr.sncf.osrd.utils.units.meters import kotlin.test.assertEquals @@ -86,6 +87,27 @@ class PathPropEndpointTest : ApiTest() { ), ) assertEquals(parsed.operationalPoints, oPs) + val firstTrackRangeGeoLength = Distance(2464352) + val sumTrackRangeGeoLength = Distance(4630820) + // Check topological distance to geometric distance projection + // The repetition of the last two values is because of a null-length range + // on the TA3 track section + val geomProjection = + GeometricProjection( + listOf( + Offset.zero(), + Offset(1_950.meters), + Offset(3_900.meters), + Offset(3_900.meters), + ), + listOf( + Offset.zero(), + Offset(firstTrackRangeGeoLength), + Offset(sumTrackRangeGeoLength), + Offset(sumTrackRangeGeoLength), + ), + ) + assertEquals(geomProjection, parsed.geomProjection) } @Test diff --git a/editoast/core_client/src/path_properties.rs b/editoast/core_client/src/path_properties.rs index 872fadfba4c..dc9f37368c3 100644 --- a/editoast/core_client/src/path_properties.rs +++ b/editoast/core_client/src/path_properties.rs @@ -35,6 +35,8 @@ pub struct PathPropertiesResponse { pub operational_points: Vec, /// Zones along the path pub zones: PropertyZoneValues, + // Projection from topological offset to geometric offset (or vice versa) + pub geom_projection: GeometryProjection, } /// Property f64 values along a path. Each value is associated to a range of the path. @@ -162,6 +164,33 @@ impl PropertyZoneValues { } } +/// Projection to map topological offset to geometric offset (or reversed). +/// topo_offsets and geom_offsets are the same size +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[schema(as = CorePropertyGeometryProjection)] +pub struct GeometryProjection { + /// Topological offsets in millimeters. + /// Starts with 0 and is increasing. + #[schema(min_items = 2)] + topo_offsets: Vec, + /// Geometric offsets in millimeters, processed using haversine formula. + /// Starts with 0 and is increasing. + #[schema(min_items = 2)] + geom_offsets: Vec, +} + +#[cfg(test)] +impl GeometryProjection { + pub fn new(topo_offsets: Vec, geom_offsets: Vec) -> Self { + assert_eq!(topo_offsets.len(), geom_offsets.len()); + assert!(topo_offsets.len() >= 2); + Self { + topo_offsets, + geom_offsets, + } + } +} + impl AsCoreRequest> for PathPropertiesRequest<'_> { const URL_PATH: &'static str = "/path_properties"; diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 9992a9583af..ad5bf14b434 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -6208,6 +6208,35 @@ components: description: |- The path offset in mm of each path item given as input of the pathfinding The first value is always `0` (beginning of the path) and the last one is always equal to the `length` of the path in mm + CorePropertyGeometryProjection: + type: object + description: |- + Projection to map topological offset to geometric offset (or reversed). + topo_offsets and geom_offsets are the same size + required: + - topo_offsets + - geom_offsets + properties: + geom_offsets: + type: array + items: + type: integer + format: int64 + minimum: 0 + description: |- + Geometric offsets in millimeters, processed using haversine formula. + Starts with 0 and is increasing. + minItems: 2 + topo_offsets: + type: array + items: + type: integer + format: int64 + minimum: 0 + description: |- + Topological offsets in millimeters. + Starts with 0 and is increasing. + minItems: 2 CoreReportTrain: type: object required: @@ -13148,6 +13177,7 @@ components: - geometry - operational_points - zones + - geom_projection properties: curves: oneOf: @@ -13232,6 +13262,9 @@ components: - non_electrified description: List of `n+1` values associated to the ranges description: Electrification modes and neutral section along the path + geom_projection: + $ref: '#/components/schemas/CorePropertyGeometryProjection' + description: Curve to map topological offset to geometric offset on the path geometry: $ref: '#/components/schemas/GeoJsonLineString' description: Geometry of the path diff --git a/editoast/src/views/path/properties.rs b/editoast/src/views/path/properties.rs index 9fabdad0e16..31db9764d63 100644 --- a/editoast/src/views/path/properties.rs +++ b/editoast/src/views/path/properties.rs @@ -12,6 +12,7 @@ use axum::extract::Json; use axum::extract::Path; use axum::extract::State; use common::geometry::GeoJsonLineString; +use core_client::path_properties::GeometryProjection; use core_client::path_properties::OperationalPointOnPath; use core_client::path_properties::PathPropertiesRequest; use core_client::path_properties::PropertyElectrificationValues; @@ -56,6 +57,8 @@ pub(in crate::views) struct PathProperties { /// Zones along the path #[schema(inline)] zones: PropertyZoneValues, + /// Curve to map topological offset to geometric offset on the path + geom_projection: GeometryProjection, } impl From for PathProperties { @@ -67,6 +70,7 @@ impl From for PathProperti geometry: response.geometry, operational_points: response.operational_points, zones: response.zones, + geom_projection: response.geom_projection, } } } @@ -123,6 +127,7 @@ pub(in crate::views) async fn post( mod tests { use axum::http::StatusCode; use core_client::mocking::MockingClient; + use core_client::path_properties::GeometryProjection; use core_client::path_properties::OperationalPointOnPath; use core_client::path_properties::PropertyElectrificationValue; use core_client::path_properties::PropertyElectrificationValues; @@ -150,6 +155,7 @@ mod tests { ]])), operational_points: vec![OperationalPointOnPath::new_test("1", 0, "1")], zones: PropertyZoneValues::new(vec![0, 1], vec!["Zone 1".into()]), + geom_projection: GeometryProjection::new(vec![0, 1], vec![0, 0]), } } diff --git a/front/src/applications/operationalStudies/hooks/__tests__/useSimulationResults.spec.ts b/front/src/applications/operationalStudies/hooks/__tests__/useSimulationResults.spec.ts index 11c49d7e969..fc84e3cdf9f 100644 --- a/front/src/applications/operationalStudies/hooks/__tests__/useSimulationResults.spec.ts +++ b/front/src/applications/operationalStudies/hooks/__tests__/useSimulationResults.spec.ts @@ -110,6 +110,7 @@ describe('useSimulationResults', () => { geometry: { type: 'LineString', coordinates: [] }, operational_points: [], slopes: { boundaries: [], values: [0] }, + geom_projection: { topo_offsets: [0, 1000], geom_offsets: [0, 900] }, } as unknown as PathProperties; const preparedPathPropertiesBase = { diff --git a/front/src/applications/stdcm/utils/fetchPathProperties.ts b/front/src/applications/stdcm/utils/fetchPathProperties.ts index 715bee7dbc0..46aed290731 100644 --- a/front/src/applications/stdcm/utils/fetchPathProperties.ts +++ b/front/src/applications/stdcm/utils/fetchPathProperties.ts @@ -82,6 +82,7 @@ const fetchPathProperties = async ( curves: result.curves, electrifications: result.electrifications, operational_points: result.operational_points, + geom_projection: result.geom_projection, }; } catch (error) { console.error('Error fetching path properties:', error); diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index ad28ed60813..1ccd74a61dc 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -3659,6 +3659,14 @@ export type InfraObjectWithGeometry = { obj_id: string; railjson: object; }; +export type CorePropertyGeometryProjection = { + /** Geometric offsets in millimeters, processed using haversine formula. + Starts with 0 and is increasing. */ + geom_offsets: number[]; + /** Topological offsets in millimeters. + Starts with 0 and is increasing. */ + topo_offsets: number[]; +}; export type CoreOperationalPointOnPath = { country_code: string; /** Id of the operational point */ @@ -3707,6 +3715,8 @@ export type PathProperties = { } )[]; }; + /** Curve to map topological offset to geometric offset on the path */ + geom_projection: CorePropertyGeometryProjection; /** Geometry of the path */ geometry: GeoJsonLineString; /** Operational points along the path */ diff --git a/osrd_schemas/osrd_schemas/models.py b/osrd_schemas/osrd_schemas/models.py index 246bb39da01..28cf1afe553 100644 --- a/osrd_schemas/osrd_schemas/models.py +++ b/osrd_schemas/osrd_schemas/models.py @@ -234,6 +234,32 @@ class PathItemPosition(RootModel[int]): root: Annotated[int, Field(ge=0)] +class GeomOffset(RootModel[int]): + root: Annotated[int, Field(ge=0)] + + +class TopoOffset(RootModel[int]): + root: Annotated[int, Field(ge=0)] + + +class CorePropertyGeometryProjection(BaseModel): + """ + Projection to map topological offset to geometric offset (or reversed). + topo_offsets and geom_offsets are the same size + """ + + geom_offsets: Annotated[list[GeomOffset], Field(min_length=2)] + """ + Geometric offsets in millimeters, processed using haversine formula. + Starts with 0 and is increasing. + """ + topo_offsets: Annotated[list[TopoOffset], Field(min_length=2)] + """ + Topological offsets in millimeters. + Starts with 0 and is increasing. + """ + + class PathItemTime(RootModel[int]): root: Annotated[int, Field(ge=0)] @@ -7324,6 +7350,10 @@ class PathProperties(BaseModel): """ Electrification modes and neutral section along the path """ + geom_projection: CorePropertyGeometryProjection + """ + Curve to map topological offset to geometric offset on the path + """ geometry: GeoJsonLineString """ Geometry of the path diff --git a/tests/tests/test_pathfinding.py b/tests/tests/test_pathfinding.py index 9408d2c0e7b..ad77030c293 100644 --- a/tests/tests/test_pathfinding.py +++ b/tests/tests/test_pathfinding.py @@ -288,4 +288,5 @@ def test_start_ws_v1_path(session: Session, small_infra: Infra): "boundaries": [], "values": ["zone.[DA2:DECREASING, buffer_stop.0:INCREASING]"], }, + "geom_projection": {"topo_offsets": [0, 300000], "geom_offsets": [0, 379131]}, }