From 486da1f336818c6b0dcabf685e20907e9b1fcbd8 Mon Sep 17 00:00:00 2001 From: Arthur Bougeard Date: Mon, 29 Jun 2026 14:50:18 +0200 Subject: [PATCH 1/3] core, editoast: add topological to geometric offset projection on /path_properties endpoint - Change `TrainPath` interface in core to access the path's partial track sections - Add property `geom_projection` to core and editoast APIs - Test the computing of `geom_projection` in core Signed-off-by: Arthur Bougeard --- .../path/implementations/TrainPathImpl.kt | 2 +- .../fr/sncf/osrd/path/interfaces/TrainPath.kt | 2 ++ .../main/kotlin/fr/sncf/osrd/geom/Point.kt | 16 +++++++++ .../api/path_properties/PathPropResponse.kt | 20 +++++++++++ .../PathPropResponseConverter.kt | 35 +++++++++++++++++++ .../osrd/pathfinding/PathPropEndpointTest.kt | 26 ++++++++++++++ editoast/core_client/src/path_properties.rs | 28 +++++++++++++++ editoast/openapi.yaml | 33 +++++++++++++++++ editoast/src/views/path/properties.rs | 6 ++++ .../__tests__/useSimulationResults.spec.ts | 1 + .../stdcm/utils/fetchPathProperties.ts | 1 + front/src/common/api/generatedEditoastApi.ts | 10 ++++++ osrd_schemas/osrd_schemas/models.py | 30 ++++++++++++++++ tests/tests/test_pathfinding.py | 1 + 14 files changed, 210 insertions(+), 1 deletion(-) 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/Point.kt b/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/Point.kt index de26bad15cd..47362a46ae7 100644 --- a/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/Point.kt +++ b/core/osrd-geom/src/main/kotlin/fr/sncf/osrd/geom/Point.kt @@ -1,8 +1,13 @@ package fr.sncf.osrd.geom +import kotlin.math.atan2 import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin import kotlin.math.sqrt +const val AVERAGE_EARTH_RADIUS = 6371008.8 + @JvmRecord data class Point(@JvmField val lat: Double, @JvmField val lon: Double) { /** @@ -19,6 +24,17 @@ data class Point(@JvmField val lat: Double, @JvmField val lon: Double) { return WGS84Interpolator.EARTH_RADIUS * sqrt(xDiff * xDiff + yDiff * yDiff) } + fun haversineDistanceAsMeters(other: Point): Double { + val dLon = Math.toRadians(other.lon - lon) + val dLat = Math.toRadians(other.lat - lat) + val lat1 = Math.toRadians(lat) + val lat2 = Math.toRadians(other.lat) + + val a = sin(dLat / 2).pow(2) + sin(dLon / 2).pow(2) * cos(lat1) * cos(lat2) + val theta = 2 * atan2(sqrt(a), sqrt(1 - a)) + return theta * AVERAGE_EARTH_RADIUS + } + override fun toString(): String { return String.format("{lat=%f, lon=%f}", lat, lon) } diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt index 0467418972a..8fd9650bfdf 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt @@ -9,6 +9,7 @@ import fr.sncf.osrd.api.RangeValues import fr.sncf.osrd.path.interfaces.PhysicsPath import fr.sncf.osrd.railjson.schema.geom.RJSLineString import fr.sncf.osrd.utils.json.UnitAdapterFactory +import fr.sncf.osrd.utils.units.Distance import fr.sncf.osrd.utils.units.Offset class PathPropResponse( @@ -18,6 +19,7 @@ class PathPropResponse( val geometry: RJSLineString, @Json(name = "operational_points") val operationalPoints: List, val zones: RangeValues, + @Json(name = "geom_projection") val geomProjection: GeometricProjection, ) interface Electrification @@ -54,6 +56,24 @@ 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) + for (i in 0..<(topoOffsets.size - 1)) { + assert(topoOffsets[i] <= topoOffsets[i + 1]) + assert(geomOffsets[i] <= geomOffsets[i + 1]) + } + } +} + 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..97b3563e2cd 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,10 +7,13 @@ 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.Distance +import fr.sncf.osrd.utils.units.Length import fr.sncf.osrd.utils.units.Offset fun makePathPropResponse(pathProperties: TrainPath, rawInfra: RawSignalingInfra): 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,34 @@ 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(Distance.fromMeters(rawInfra.getTrackChunkGeom(it).length)) + } + .reduce { acc, length -> acc + length.distance } + } + + val geomOffsets = mutableListOf>(Offset.zero()) + val topoOffsets = mutableListOf>(Offset.zero()) + + trackRanges.forEachIndexed { i, range -> + val rangeTopoLength = range.length + val trackSectionTopoLength = range.objectLength + val trackSectionGeomLength = getTrackSectionGeometricLength(range.value.value) + topoOffsets.addLast(topoOffsets.last() + rangeTopoLength) + val proportion = rangeTopoLength / trackSectionTopoLength.distance + 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..dfb337f9e1f 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt @@ -5,7 +5,9 @@ import fr.sncf.osrd.api.DirectionalTrackRange import fr.sncf.osrd.api.RangeValues import fr.sncf.osrd.api.path_properties.* import fr.sncf.osrd.cli.RqFake +import fr.sncf.osrd.geom.Point 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 +88,30 @@ class PathPropEndpointTest : ApiTest() { ), ) assertEquals(parsed.operationalPoints, oPs) + // Check topological distance to geometric distance projection + val trackTA0GeoLength = Point(49.5, -0.4).distanceAsMeters(Point(49.5, -0.365)) * 1000 + val trackTA1GeoLength = Point(49.4999, -0.4).distanceAsMeters(Point(49.4999, -0.37)) * 1000 + val firstTrackRangeProportion = 1950.0 / 2000.0 + val firstTrackRangeLength = (firstTrackRangeProportion * trackTA0GeoLength).toLong() + val secondTrackRangeLength = trackTA1GeoLength.toLong() + // 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(Distance(firstTrackRangeLength)), + Offset(Distance(firstTrackRangeLength + secondTrackRangeLength)), + Offset(Distance(firstTrackRangeLength + secondTrackRangeLength)), + ), + ) + 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..5dc1c987ba1 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 topologic offset to geometric offset + pub geom_projection: GeometryProjection, } /// Property f64 values along a path. Each value is associated to a range of the path. @@ -162,6 +164,32 @@ 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. + /// Starts with 0 and is increasing. + #[schema(min_items = 2)] + geom_offsets: Vec, +} + +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..7c870a62593 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. + 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 topologic 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..3698b6926e3 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 topologic 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..5ea462f0cf0 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. + 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 topologic 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..fe4e0d58f21 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. + 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 topologic 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..126e39c6461 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, 379556]}, } From 993ffe10fd009594d8d831005110ea5df2aaa373 Mon Sep 17 00:00:00 2001 From: Arthur Bougeard Date: Thu, 24 Sep 2026 14:40:47 +0200 Subject: [PATCH 2/3] fixup! core, editoast: add topological to geometric offset projection on /path_properties endpoint --- .../src/main/kotlin/fr/sncf/osrd/geom/LineString.kt | 4 ++-- .../src/main/kotlin/fr/sncf/osrd/geom/Point.kt | 5 +++++ .../osrd/api/path_properties/PathPropResponse.kt | 6 ++---- .../api/path_properties/PathPropResponseConverter.kt | 10 ++++++---- .../fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt | 12 +++--------- editoast/openapi.yaml | 2 +- editoast/src/views/path/properties.rs | 2 +- front/src/common/api/generatedEditoastApi.ts | 2 +- osrd_schemas/osrd_schemas/models.py | 2 +- tests/tests/test_pathfinding.py | 2 +- 10 files changed, 23 insertions(+), 24 deletions(-) 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..(Distance.fromMeters(rawInfra.getTrackChunkGeom(it).length)) + Length(rawInfra.getTrackChunkGeom(it).length.meters) } .reduce { acc, length -> acc + length.distance } } @@ -173,10 +173,12 @@ private fun makeGeometricProjection( trackRanges.forEachIndexed { i, range -> val rangeTopoLength = range.length - val trackSectionTopoLength = range.objectLength - val trackSectionGeomLength = getTrackSectionGeometricLength(range.value.value) 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) } 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 dfb337f9e1f..b5e45e24a5d 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt @@ -5,7 +5,6 @@ import fr.sncf.osrd.api.DirectionalTrackRange import fr.sncf.osrd.api.RangeValues import fr.sncf.osrd.api.path_properties.* import fr.sncf.osrd.cli.RqFake -import fr.sncf.osrd.geom.Point import fr.sncf.osrd.railjson.schema.common.graph.EdgeDirection import fr.sncf.osrd.utils.units.Distance import fr.sncf.osrd.utils.units.Offset @@ -89,11 +88,6 @@ class PathPropEndpointTest : ApiTest() { ) assertEquals(parsed.operationalPoints, oPs) // Check topological distance to geometric distance projection - val trackTA0GeoLength = Point(49.5, -0.4).distanceAsMeters(Point(49.5, -0.365)) * 1000 - val trackTA1GeoLength = Point(49.4999, -0.4).distanceAsMeters(Point(49.4999, -0.37)) * 1000 - val firstTrackRangeProportion = 1950.0 / 2000.0 - val firstTrackRangeLength = (firstTrackRangeProportion * trackTA0GeoLength).toLong() - val secondTrackRangeLength = trackTA1GeoLength.toLong() // The repetition of the last two values is because of a null-length range // on the TA3 track section val geomProjection = @@ -106,9 +100,9 @@ class PathPropEndpointTest : ApiTest() { ), listOf( Offset.zero(), - Offset(Distance(firstTrackRangeLength)), - Offset(Distance(firstTrackRangeLength + secondTrackRangeLength)), - Offset(Distance(firstTrackRangeLength + secondTrackRangeLength)), + Offset(Distance(2464352)), + Offset(Distance(4630820)), + Offset(Distance(4630820)), ), ) assertEquals(geomProjection, parsed.geomProjection) diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 7c870a62593..08915bbe70f 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -13264,7 +13264,7 @@ components: description: Electrification modes and neutral section along the path geom_projection: $ref: '#/components/schemas/CorePropertyGeometryProjection' - description: Curve to map topologic offset to geometric offset on the path + 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 3698b6926e3..31db9764d63 100644 --- a/editoast/src/views/path/properties.rs +++ b/editoast/src/views/path/properties.rs @@ -57,7 +57,7 @@ pub(in crate::views) struct PathProperties { /// Zones along the path #[schema(inline)] zones: PropertyZoneValues, - /// Curve to map topologic offset to geometric offset on the path + /// Curve to map topological offset to geometric offset on the path geom_projection: GeometryProjection, } diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 5ea462f0cf0..59f90c24612 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -3715,7 +3715,7 @@ export type PathProperties = { } )[]; }; - /** Curve to map topologic offset to geometric offset on the path */ + /** Curve to map topological offset to geometric offset on the path */ geom_projection: CorePropertyGeometryProjection; /** Geometry of the path */ geometry: GeoJsonLineString; diff --git a/osrd_schemas/osrd_schemas/models.py b/osrd_schemas/osrd_schemas/models.py index fe4e0d58f21..a65ec4b5e61 100644 --- a/osrd_schemas/osrd_schemas/models.py +++ b/osrd_schemas/osrd_schemas/models.py @@ -7352,7 +7352,7 @@ class PathProperties(BaseModel): """ geom_projection: CorePropertyGeometryProjection """ - Curve to map topologic offset to geometric offset on the path + Curve to map topological offset to geometric offset on the path """ geometry: GeoJsonLineString """ diff --git a/tests/tests/test_pathfinding.py b/tests/tests/test_pathfinding.py index 126e39c6461..ad77030c293 100644 --- a/tests/tests/test_pathfinding.py +++ b/tests/tests/test_pathfinding.py @@ -288,5 +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, 379556]}, + "geom_projection": {"topo_offsets": [0, 300000], "geom_offsets": [0, 379131]}, } From f819bd4e9f03455c3bafec8ab28ff75ec0048f4c Mon Sep 17 00:00:00 2001 From: Arthur Bougeard Date: Fri, 2 Oct 2026 18:19:52 +0200 Subject: [PATCH 3/3] fixup! core, editoast: add topological to geometric offset projection on /path_properties endpoint --- .../fr/sncf/osrd/api/path_properties/PathPropResponse.kt | 6 ++++-- .../fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt | 8 +++++--- editoast/core_client/src/path_properties.rs | 5 +++-- editoast/openapi.yaml | 2 +- front/src/common/api/generatedEditoastApi.ts | 2 +- osrd_schemas/osrd_schemas/models.py | 2 +- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt index 260d6548ce8..ff1595edddc 100644 --- a/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt +++ b/core/src/main/kotlin/fr/sncf/osrd/api/path_properties/PathPropResponse.kt @@ -19,6 +19,8 @@ class PathPropResponse( val geometry: RJSLineString, @Json(name = "operational_points") val operationalPoints: List, val zones: RangeValues, + // Geometric to topological projection (or vice-versa) using haversine formula for geometric + // offsets @Json(name = "geom_projection") val geomProjection: GeometricProjection, ) @@ -67,8 +69,8 @@ data class GeometricProjection( // Each list must start by 0 assert(topoOffsets[0].distance == Distance.ZERO && geomOffsets[0].distance == Distance.ZERO) // Each list must be increasing (not strictly) - topoOffsets.zipWithNext().all { it.first <= it.second } - geomOffsets.zipWithNext().all { it.first <= it.second } + assert(topoOffsets.zipWithNext().all { it.first <= it.second }) + assert(geomOffsets.zipWithNext().all { it.first <= it.second }) } } 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 b5e45e24a5d..6931f200f6e 100644 --- a/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt +++ b/core/src/test/kotlin/fr/sncf/osrd/pathfinding/PathPropEndpointTest.kt @@ -87,6 +87,8 @@ 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 @@ -100,9 +102,9 @@ class PathPropEndpointTest : ApiTest() { ), listOf( Offset.zero(), - Offset(Distance(2464352)), - Offset(Distance(4630820)), - Offset(Distance(4630820)), + Offset(firstTrackRangeGeoLength), + Offset(sumTrackRangeGeoLength), + Offset(sumTrackRangeGeoLength), ), ) assertEquals(geomProjection, parsed.geomProjection) diff --git a/editoast/core_client/src/path_properties.rs b/editoast/core_client/src/path_properties.rs index 5dc1c987ba1..dc9f37368c3 100644 --- a/editoast/core_client/src/path_properties.rs +++ b/editoast/core_client/src/path_properties.rs @@ -35,7 +35,7 @@ pub struct PathPropertiesResponse { pub operational_points: Vec, /// Zones along the path pub zones: PropertyZoneValues, - // Projection from topologic offset to geometric offset + // Projection from topological offset to geometric offset (or vice versa) pub geom_projection: GeometryProjection, } @@ -173,12 +173,13 @@ pub struct GeometryProjection { /// Starts with 0 and is increasing. #[schema(min_items = 2)] topo_offsets: Vec, - /// Geometric offsets in millimeters. + /// 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()); diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 08915bbe70f..ad5bf14b434 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -6224,7 +6224,7 @@ components: format: int64 minimum: 0 description: |- - Geometric offsets in millimeters. + Geometric offsets in millimeters, processed using haversine formula. Starts with 0 and is increasing. minItems: 2 topo_offsets: diff --git a/front/src/common/api/generatedEditoastApi.ts b/front/src/common/api/generatedEditoastApi.ts index 59f90c24612..1ccd74a61dc 100644 --- a/front/src/common/api/generatedEditoastApi.ts +++ b/front/src/common/api/generatedEditoastApi.ts @@ -3660,7 +3660,7 @@ export type InfraObjectWithGeometry = { railjson: object; }; export type CorePropertyGeometryProjection = { - /** Geometric offsets in millimeters. + /** Geometric offsets in millimeters, processed using haversine formula. Starts with 0 and is increasing. */ geom_offsets: number[]; /** Topological offsets in millimeters. diff --git a/osrd_schemas/osrd_schemas/models.py b/osrd_schemas/osrd_schemas/models.py index a65ec4b5e61..28cf1afe553 100644 --- a/osrd_schemas/osrd_schemas/models.py +++ b/osrd_schemas/osrd_schemas/models.py @@ -250,7 +250,7 @@ class CorePropertyGeometryProjection(BaseModel): geom_offsets: Annotated[list[GeomOffset], Field(min_length=2)] """ - Geometric offsets in millimeters. + Geometric offsets in millimeters, processed using haversine formula. Starts with 0 and is increasing. """ topo_offsets: Annotated[list[TopoOffset], Field(min_length=2)]