use crate::errors::ProjectionError;
use crate::models::{AssociatedNetElement, PathDiagnosticInfo, PathMetadata};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainPath {
pub segments: Vec<AssociatedNetElement>,
pub overall_probability: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub calculated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<PathMetadata>,
}
impl TrainPath {
pub fn new(
segments: Vec<AssociatedNetElement>,
overall_probability: f64,
calculated_at: Option<DateTime<Utc>>,
metadata: Option<PathMetadata>,
) -> Result<Self, ProjectionError> {
let path = Self {
segments,
overall_probability,
calculated_at,
metadata,
};
path.validate()?;
Ok(path)
}
pub fn diagnostics(&self) -> PathDiagnosticInfo {
PathDiagnosticInfo::from_segments(&self.segments)
}
pub fn with_metadata(mut self, mut metadata: PathMetadata) -> Self {
if metadata.diagnostic_info.is_none() {
metadata.diagnostic_info = Some(self.diagnostics());
}
self.metadata = Some(metadata);
self
}
fn validate(&self) -> Result<(), ProjectionError> {
if self.segments.is_empty() {
return Err(ProjectionError::PathCalculationFailed {
reason: "TrainPath must have at least one segment".to_string(),
});
}
if !(0.0..=1.0).contains(&self.overall_probability) {
return Err(ProjectionError::InvalidGeometry(format!(
"overall_probability must be in [0, 1], got {}",
self.overall_probability
)));
}
for i in 0..self.segments.len() - 1 {
let current = &self.segments[i];
let next = &self.segments[i + 1];
if next.gnss_start_index < current.gnss_start_index {
return Err(ProjectionError::PathCalculationFailed {
reason: format!(
"Segment GNSS indices not continuous: segment {} ends at {}, segment {} starts at {}",
i, current.gnss_end_index, i + 1, next.gnss_start_index
),
});
}
}
Ok(())
}
pub fn total_fractional_length(&self) -> f64 {
self.segments.iter().map(|s| s.fractional_length()).sum()
}
pub fn netelement_ids(&self) -> Vec<&str> {
self.segments
.iter()
.map(|s| s.netelement_id.as_str())
.collect()
}
pub fn total_gnss_positions(&self) -> usize {
if self.segments.is_empty() {
return 0;
}
let first = &self.segments[0];
let last = &self.segments[self.segments.len() - 1];
last.gnss_end_index - first.gnss_start_index + 1
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_train_path_valid() {
let segments = vec![
AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap(),
AssociatedNetElement::new("NE_B".to_string(), 0.92, 0.0, 1.0, 11, 18).unwrap(),
];
let path = TrainPath::new(segments, 0.89, Some(Utc::now()), None);
assert!(path.is_ok());
let p = path.unwrap();
assert_eq!(p.segments.len(), 2);
assert_eq!(p.total_gnss_positions(), 19);
}
#[test]
fn test_train_path_empty_segments() {
let path = TrainPath::new(vec![], 0.89, Some(Utc::now()), None);
assert!(path.is_err());
}
#[test]
fn test_train_path_invalid_probability() {
let segments =
vec![AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap()];
let path = TrainPath::new(segments, 1.5, Some(Utc::now()), None);
assert!(path.is_err());
}
#[test]
fn test_train_path_negative_probability() {
let segments =
vec![AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.0, 1.0, 0, 10).unwrap()];
let path = TrainPath::new(segments, -0.1, None, None);
assert!(path.is_err());
}
#[test]
fn test_train_path_total_fractional_length() {
let segments = vec![
AssociatedNetElement {
netelement_id: "NE_A".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.9,
gnss_start_index: 0,
gnss_end_index: 10,
origin: Default::default(),
},
AssociatedNetElement {
netelement_id: "NE_B".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 0.5,
probability: 0.8,
gnss_start_index: 11,
gnss_end_index: 15,
origin: Default::default(),
},
];
let path = TrainPath::new(segments, 0.85, None, None).unwrap();
let length = path.total_fractional_length();
assert!((length - 1.5).abs() < 1e-6); }
#[test]
fn test_train_path_netelement_ids() {
let segments = vec![
AssociatedNetElement {
netelement_id: "NE_A".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.9,
gnss_start_index: 0,
gnss_end_index: 10,
origin: Default::default(),
},
AssociatedNetElement {
netelement_id: "NE_B".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.8,
gnss_start_index: 11,
gnss_end_index: 15,
origin: Default::default(),
},
];
let path = TrainPath::new(segments, 0.85, None, None).unwrap();
let ids = path.netelement_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], "NE_A");
assert_eq!(ids[1], "NE_B");
}
#[test]
fn test_train_path_diagnostics() {
let segments = vec![AssociatedNetElement {
netelement_id: "NE_A".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.9,
gnss_start_index: 0,
gnss_end_index: 10,
origin: Default::default(),
}];
let path = TrainPath::new(segments, 0.9, None, None).unwrap();
let diagnostics = path.diagnostics();
assert_eq!(diagnostics.segments.len(), 1);
assert_eq!(diagnostics.segments[0].netelement_id, "NE_A");
}
#[test]
fn test_train_path_with_metadata() {
use crate::models::PathMetadata;
let segments = vec![AssociatedNetElement {
netelement_id: "NE_A".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.9,
gnss_start_index: 0,
gnss_end_index: 10,
origin: Default::default(),
}];
let metadata = PathMetadata {
distance_scale: 10.0,
heading_scale: 2.0,
cutoff_distance: 500.0,
heading_cutoff: 5.0,
probability_threshold: 0.02,
resampling_distance: None,
fallback_mode: false,
candidate_paths_evaluated: 5,
bidirectional_path: true,
diagnostic_info: None,
};
let path = TrainPath::new(segments, 0.9, None, Some(metadata.clone())).unwrap();
assert!(path.metadata.is_some());
assert_eq!(path.metadata.as_ref().unwrap().distance_scale, 10.0);
}
#[test]
fn test_train_path_with_calculated_at() {
let segments = vec![AssociatedNetElement {
netelement_id: "NE_A".to_string(),
start_intrinsic: 0.0,
end_intrinsic: 1.0,
probability: 0.9,
gnss_start_index: 0,
gnss_end_index: 10,
origin: Default::default(),
}];
let now = Utc::now();
let path = TrainPath::new(segments, 0.9, Some(now), None).unwrap();
assert!(path.calculated_at.is_some());
}
}