Skip to main content

tp_lib_core/models/
projected_position.rs

1//! Projected GNSS position onto railway netelement
2
3use crate::models::GnssPosition;
4use geo::Point;
5use serde::{Deserialize, Serialize};
6
7/// Represents a GNSS position projected onto a railway netelement
8///
9/// A `ProjectedPosition` is the result of projecting a GNSS measurement onto the
10/// nearest railway track segment. It preserves the original GNSS data and adds:
11///
12/// - Projected coordinates on the track centerline
13/// - Measure (distance along track from netelement start)
14/// - Projection distance (perpendicular distance from original to projected point)
15/// - Netelement assignment
16///
17/// # Use Cases
18///
19/// - Calculate train progress along tracks
20/// - Analyze position accuracy and quality
21/// - Detect track deviations or sensor errors
22/// - Generate linear referencing for asset management
23///
24/// # Examples
25///
26/// ```rust,no_run
27/// use tp_lib_core::{parse_gnss_csv, parse_network_geojson, RailwayNetwork};
28/// use tp_lib_core::{project_gnss, ProjectionConfig};
29///
30/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
31/// // Load and project data
32/// let (netelements, _netrelations) = parse_network_geojson("network.geojson")?;
33/// let network = RailwayNetwork::new(netelements)?;
34/// let positions = parse_gnss_csv("gnss.csv", "EPSG:4326", "latitude", "longitude", "timestamp")?;
35///
36/// let config = ProjectionConfig::default();
37/// let projected = project_gnss(&positions, &network, &config)?;
38///
39/// // Analyze results
40/// for pos in projected {
41///     println!("Track position: {}m on {}", pos.measure_meters, pos.netelement_id);
42///     println!("Projection accuracy: {:.2}m", pos.projection_distance_meters);
43/// }
44/// # Ok(())
45/// # }
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProjectedPosition {
49    /// Original GNSS measurement (preserved)
50    pub original: GnssPosition,
51
52    /// Projected coordinates on the track axis
53    pub projected_coords: Point<f64>,
54
55    /// ID of the netelement this position was projected onto
56    pub netelement_id: String,
57
58    /// Distance along the netelement from start (in meters)
59    pub measure_meters: f64,
60
61    /// Distance between original GNSS position and projected position (in meters)
62    pub projection_distance_meters: f64,
63
64    /// Coordinate Reference System of the projected coordinates
65    pub crs: String,
66
67    /// Intrinsic coordinate (0-1 range) relative to netelement start
68    /// Only populated when projecting onto a calculated train path (US2)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub intrinsic: Option<f64>,
71}
72
73impl ProjectedPosition {
74    /// Create a new projected position
75    pub fn new(
76        original: GnssPosition,
77        projected_coords: Point<f64>,
78        netelement_id: String,
79        measure_meters: f64,
80        projection_distance_meters: f64,
81        crs: String,
82    ) -> Self {
83        Self {
84            original,
85            projected_coords,
86            netelement_id,
87            measure_meters,
88            projection_distance_meters,
89            crs,
90            intrinsic: None,
91        }
92    }
93
94    /// Create a new projected position with intrinsic coordinate (for path projection)
95    pub fn with_intrinsic(
96        original: GnssPosition,
97        projected_coords: Point<f64>,
98        netelement_id: String,
99        measure_meters: f64,
100        projection_distance_meters: f64,
101        crs: String,
102        intrinsic: f64,
103    ) -> Self {
104        Self {
105            original,
106            projected_coords,
107            netelement_id,
108            measure_meters,
109            projection_distance_meters,
110            crs,
111            intrinsic: Some(intrinsic),
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use chrono::{FixedOffset, TimeZone};
120    use std::collections::HashMap;
121
122    #[test]
123    fn test_projected_position_creation() {
124        let timestamp = FixedOffset::east_opt(3600)
125            .unwrap()
126            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
127            .unwrap();
128
129        let original = GnssPosition {
130            latitude: 50.8503,
131            longitude: 4.3517,
132            timestamp,
133            crs: "EPSG:4326".to_string(),
134            metadata: HashMap::new(),
135            heading: None,
136            distance: None,
137        };
138
139        let projected = ProjectedPosition::new(
140            original.clone(),
141            Point::new(4.3517, 50.8503),
142            "NE001".to_string(),
143            100.5,
144            2.3,
145            "EPSG:4326".to_string(),
146        );
147
148        assert_eq!(projected.netelement_id, "NE001");
149        assert_eq!(projected.measure_meters, 100.5);
150        assert_eq!(projected.projection_distance_meters, 2.3);
151        assert!(projected.intrinsic.is_none());
152    }
153
154    #[test]
155    fn test_projected_position_with_intrinsic() {
156        let timestamp = FixedOffset::east_opt(3600)
157            .unwrap()
158            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
159            .unwrap();
160
161        let original = GnssPosition {
162            latitude: 50.8503,
163            longitude: 4.3517,
164            timestamp,
165            crs: "EPSG:4326".to_string(),
166            metadata: HashMap::new(),
167            heading: None,
168            distance: None,
169        };
170
171        let projected = ProjectedPosition::with_intrinsic(
172            original.clone(),
173            Point::new(4.3517, 50.8503),
174            "NE001".to_string(),
175            100.5,
176            2.3,
177            "EPSG:4326".to_string(),
178            0.75,
179        );
180
181        assert_eq!(projected.netelement_id, "NE001");
182        assert_eq!(projected.measure_meters, 100.5);
183        assert_eq!(projected.projection_distance_meters, 2.3);
184        assert_eq!(projected.intrinsic, Some(0.75));
185    }
186
187    #[test]
188    fn test_projected_position_preserves_original_data() {
189        let timestamp = FixedOffset::east_opt(3600)
190            .unwrap()
191            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
192            .unwrap();
193
194        let mut metadata = HashMap::new();
195        metadata.insert("speed".to_string(), "50.5".to_string());
196        metadata.insert("quality".to_string(), "high".to_string());
197
198        let original = GnssPosition {
199            latitude: 50.8503,
200            longitude: 4.3517,
201            timestamp,
202            crs: "EPSG:4326".to_string(),
203            metadata: metadata.clone(),
204            heading: Some(90.0),
205            distance: Some(150.5),
206        };
207
208        let projected = ProjectedPosition::new(
209            original.clone(),
210            Point::new(4.3517, 50.8503),
211            "NE001".to_string(),
212            100.5,
213            2.3,
214            "EPSG:4326".to_string(),
215        );
216
217        // Verify original data is preserved
218        assert_eq!(projected.original.latitude, 50.8503);
219        assert_eq!(projected.original.longitude, 4.3517);
220        assert_eq!(projected.original.heading, Some(90.0));
221        assert_eq!(projected.original.distance, Some(150.5));
222        assert_eq!(projected.original.metadata, metadata);
223        assert_eq!(projected.original.timestamp, timestamp);
224    }
225
226    #[test]
227    fn test_projected_position_different_crs() {
228        let timestamp = FixedOffset::east_opt(0)
229            .unwrap()
230            .with_ymd_and_hms(2025, 12, 9, 14, 30, 0)
231            .unwrap();
232
233        let original = GnssPosition {
234            latitude: 50.8503,
235            longitude: 4.3517,
236            timestamp,
237            crs: "EPSG:4326".to_string(),
238            metadata: HashMap::new(),
239            heading: None,
240            distance: None,
241        };
242
243        // Projected in Lambert 72
244        let projected = ProjectedPosition::new(
245            original.clone(),
246            Point::new(649775.0, 667946.0), // Lambert 72 coordinates
247            "NE001".to_string(),
248            100.5,
249            2.3,
250            "EPSG:31370".to_string(), // Belgian Lambert 72
251        );
252
253        assert_eq!(projected.crs, "EPSG:31370");
254        assert_eq!(projected.original.crs, "EPSG:4326");
255        assert_ne!(projected.projected_coords.x(), projected.original.longitude);
256    }
257}