Skip to main content

tp_lib_core/models/
train_path.rs

1//! Continuous train path through the rail network
2
3use crate::errors::ProjectionError;
4use crate::models::{AssociatedNetElement, PathDiagnosticInfo, PathMetadata};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Represents a continuous train path through the rail network
9///
10/// A TrainPath is an ordered sequence of netelements (track segments) that
11/// the train traversed, calculated from GNSS coordinates and network topology.
12///
13/// # Examples
14///
15/// ```
16/// use tp_lib_core::{TrainPath, AssociatedNetElement};
17/// use chrono::Utc;
18///
19/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
20/// let segments = vec![
21///     AssociatedNetElement::new(
22///         "NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10
23///     )?,
24///     AssociatedNetElement::new(
25///         "NE_B".to_string(), 0.92, 0.0, 1.0, 11, 18
26///     )?,
27/// ];
28///
29/// let path = TrainPath::new(
30///     segments,
31///     0.89,
32///     Some(Utc::now()),
33///     None,
34/// )?;
35///
36/// assert_eq!(path.segments.len(), 2);
37/// assert_eq!(path.overall_probability, 0.89);
38/// # Ok(())
39/// # }
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TrainPath {
43    /// Ordered sequence of netelements in the path
44    /// Order represents the direction of travel from first to last GNSS position
45    pub segments: Vec<AssociatedNetElement>,
46
47    /// Overall probability score for this path (0.0 to 1.0)
48    /// Calculated as length-weighted average of segment probabilities,
49    /// averaged between forward and backward path calculations
50    pub overall_probability: f64,
51
52    /// Timestamp when this path was calculated
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub calculated_at: Option<DateTime<Utc>>,
55
56    /// Algorithm configuration metadata
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub metadata: Option<PathMetadata>,
59}
60
61impl TrainPath {
62    /// Create a new train path with validation
63    pub fn new(
64        segments: Vec<AssociatedNetElement>,
65        overall_probability: f64,
66        calculated_at: Option<DateTime<Utc>>,
67        metadata: Option<PathMetadata>,
68    ) -> Result<Self, ProjectionError> {
69        let path = Self {
70            segments,
71            overall_probability,
72            calculated_at,
73            metadata,
74        };
75
76        path.validate()?;
77        Ok(path)
78    }
79
80    /// Build diagnostic info from the current segments (order, intrinsics, probabilities)
81    pub fn diagnostics(&self) -> PathDiagnosticInfo {
82        PathDiagnosticInfo::from_segments(&self.segments)
83    }
84
85    /// Attach metadata, auto-populating diagnostic info if not provided
86    pub fn with_metadata(mut self, mut metadata: PathMetadata) -> Self {
87        if metadata.diagnostic_info.is_none() {
88            metadata.diagnostic_info = Some(self.diagnostics());
89        }
90
91        self.metadata = Some(metadata);
92        self
93    }
94
95    /// Validate train path
96    fn validate(&self) -> Result<(), ProjectionError> {
97        // Must have at least one segment
98        if self.segments.is_empty() {
99            return Err(ProjectionError::PathCalculationFailed {
100                reason: "TrainPath must have at least one segment".to_string(),
101            });
102        }
103
104        // Overall probability must be in [0, 1]
105        if !(0.0..=1.0).contains(&self.overall_probability) {
106            return Err(ProjectionError::InvalidGeometry(format!(
107                "overall_probability must be in [0, 1], got {}",
108                self.overall_probability
109            )));
110        }
111
112        // Validate segment continuity (GNSS indices should be continuous or overlapping)
113        for i in 0..self.segments.len() - 1 {
114            let current = &self.segments[i];
115            let next = &self.segments[i + 1];
116
117            // Next segment should start at or after current segment's last position
118            if next.gnss_start_index < current.gnss_start_index {
119                return Err(ProjectionError::PathCalculationFailed {
120                    reason: format!(
121                        "Segment GNSS indices not continuous: segment {} ends at {}, segment {} starts at {}",
122                        i, current.gnss_end_index, i + 1, next.gnss_start_index
123                    ),
124                });
125            }
126        }
127
128        Ok(())
129    }
130
131    /// Calculate total path length (sum of fractional lengths)
132    pub fn total_fractional_length(&self) -> f64 {
133        self.segments.iter().map(|s| s.fractional_length()).sum()
134    }
135
136    /// Get netelement IDs in traversal order
137    pub fn netelement_ids(&self) -> Vec<&str> {
138        self.segments
139            .iter()
140            .map(|s| s.netelement_id.as_str())
141            .collect()
142    }
143
144    /// Total number of GNSS positions in path
145    pub fn total_gnss_positions(&self) -> usize {
146        if self.segments.is_empty() {
147            return 0;
148        }
149
150        let first = &self.segments[0];
151        let last = &self.segments[self.segments.len() - 1];
152
153        last.gnss_end_index - first.gnss_start_index + 1
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn test_train_path_valid() {
163        let segments = vec![
164            AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap(),
165            AssociatedNetElement::new("NE_B".to_string(), 0.92, 0.0, 1.0, 11, 18).unwrap(),
166        ];
167
168        let path = TrainPath::new(segments, 0.89, Some(Utc::now()), None);
169
170        assert!(path.is_ok());
171        let p = path.unwrap();
172        assert_eq!(p.segments.len(), 2);
173        assert_eq!(p.total_gnss_positions(), 19);
174    }
175
176    #[test]
177    fn test_train_path_empty_segments() {
178        let path = TrainPath::new(vec![], 0.89, Some(Utc::now()), None);
179
180        assert!(path.is_err());
181    }
182
183    #[test]
184    fn test_train_path_invalid_probability() {
185        let segments =
186            vec![AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap()];
187
188        let path = TrainPath::new(segments, 1.5, Some(Utc::now()), None); // Invalid: > 1.0
189
190        assert!(path.is_err());
191    }
192
193    #[test]
194    fn test_train_path_negative_probability() {
195        let segments =
196            vec![AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap()];
197
198        let path = TrainPath::new(segments, -0.1, None, None);
199
200        assert!(path.is_err());
201    }
202
203    #[test]
204    fn test_train_path_total_fractional_length() {
205        let segments = vec![
206            AssociatedNetElement {
207                netelement_id: "NE_A".to_string(),
208                start_intrinsic: 0.0,
209                end_intrinsic: 1.0,
210                probability: 0.9,
211                gnss_start_index: 0,
212                gnss_end_index: 10,
213                origin: Default::default(),
214            },
215            AssociatedNetElement {
216                netelement_id: "NE_B".to_string(),
217                start_intrinsic: 0.0,
218                end_intrinsic: 0.5,
219                probability: 0.8,
220                gnss_start_index: 11,
221                gnss_end_index: 15,
222                origin: Default::default(),
223            },
224        ];
225
226        let path = TrainPath::new(segments, 0.85, None, None).unwrap();
227        let length = path.total_fractional_length();
228
229        assert!((length - 1.5).abs() < 1e-6); // 1.0 + 0.5 = 1.5
230    }
231
232    #[test]
233    fn test_train_path_netelement_ids() {
234        let segments = vec![
235            AssociatedNetElement {
236                netelement_id: "NE_A".to_string(),
237                start_intrinsic: 0.0,
238                end_intrinsic: 1.0,
239                probability: 0.9,
240                gnss_start_index: 0,
241                gnss_end_index: 10,
242                origin: Default::default(),
243            },
244            AssociatedNetElement {
245                netelement_id: "NE_B".to_string(),
246                start_intrinsic: 0.0,
247                end_intrinsic: 1.0,
248                probability: 0.8,
249                gnss_start_index: 11,
250                gnss_end_index: 15,
251                origin: Default::default(),
252            },
253        ];
254
255        let path = TrainPath::new(segments, 0.85, None, None).unwrap();
256        let ids = path.netelement_ids();
257
258        assert_eq!(ids.len(), 2);
259        assert_eq!(ids[0], "NE_A");
260        assert_eq!(ids[1], "NE_B");
261    }
262
263    #[test]
264    fn test_train_path_diagnostics() {
265        let segments = vec![AssociatedNetElement {
266            netelement_id: "NE_A".to_string(),
267            start_intrinsic: 0.0,
268            end_intrinsic: 1.0,
269            probability: 0.9,
270            gnss_start_index: 0,
271            gnss_end_index: 10,
272            origin: Default::default(),
273        }];
274
275        let path = TrainPath::new(segments, 0.9, None, None).unwrap();
276        let diagnostics = path.diagnostics();
277
278        assert_eq!(diagnostics.segments.len(), 1);
279        assert_eq!(diagnostics.segments[0].netelement_id, "NE_A");
280    }
281
282    #[test]
283    fn test_train_path_with_metadata() {
284        use crate::models::PathMetadata;
285
286        let segments = vec![AssociatedNetElement {
287            netelement_id: "NE_A".to_string(),
288            start_intrinsic: 0.0,
289            end_intrinsic: 1.0,
290            probability: 0.9,
291            gnss_start_index: 0,
292            gnss_end_index: 10,
293            origin: Default::default(),
294        }];
295
296        let metadata = PathMetadata {
297            distance_scale: 10.0,
298            heading_scale: 2.0,
299            cutoff_distance: 500.0,
300            heading_cutoff: 5.0,
301            probability_threshold: 0.02,
302            resampling_distance: None,
303            fallback_mode: false,
304            candidate_paths_evaluated: 5,
305            bidirectional_path: true,
306            diagnostic_info: None,
307        };
308
309        let path = TrainPath::new(segments, 0.9, None, Some(metadata.clone())).unwrap();
310
311        assert!(path.metadata.is_some());
312        assert_eq!(path.metadata.as_ref().unwrap().distance_scale, 10.0);
313    }
314
315    #[test]
316    fn test_train_path_with_calculated_at() {
317        let segments = vec![AssociatedNetElement {
318            netelement_id: "NE_A".to_string(),
319            start_intrinsic: 0.0,
320            end_intrinsic: 1.0,
321            probability: 0.9,
322            gnss_start_index: 0,
323            gnss_end_index: 10,
324            origin: Default::default(),
325        }];
326
327        let now = Utc::now();
328        let path = TrainPath::new(segments, 0.9, Some(now), None).unwrap();
329
330        assert!(path.calculated_at.is_some());
331    }
332}