1use crate::errors::ProjectionError;
4use crate::models::{AssociatedNetElement, PathDiagnosticInfo, PathMetadata};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct TrainPath {
43 pub segments: Vec<AssociatedNetElement>,
46
47 pub overall_probability: f64,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub calculated_at: Option<DateTime<Utc>>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub metadata: Option<PathMetadata>,
59}
60
61impl TrainPath {
62 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 pub fn diagnostics(&self) -> PathDiagnosticInfo {
82 PathDiagnosticInfo::from_segments(&self.segments)
83 }
84
85 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 fn validate(&self) -> Result<(), ProjectionError> {
97 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 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 for i in 0..self.segments.len() - 1 {
114 let current = &self.segments[i];
115 let next = &self.segments[i + 1];
116
117 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 pub fn total_fractional_length(&self) -> f64 {
133 self.segments.iter().map(|s| s.fractional_length()).sum()
134 }
135
136 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 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); 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); }
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}