Skip to main content

tp_lib_core/models/
associated_net_element.rs

1//! Netelement within a calculated train path
2
3use crate::errors::ProjectionError;
4use crate::models::PathOrigin;
5use serde::{Deserialize, Serialize};
6
7/// Represents a netelement within a calculated train path
8///
9/// Contains the netelement ID, probability score, and projection details for
10/// GNSS positions associated with this segment in the path.
11///
12/// # Examples
13///
14/// ```
15/// use tp_lib_core::AssociatedNetElement;
16///
17/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
18/// let segment = AssociatedNetElement::new(
19///     "NE_A".to_string(),
20///     0.87,
21///     0.25,
22///     0.78,
23///     5,
24///     12,
25/// )?;
26///
27/// // This segment spans from 25% to 78% along netelement NE_A
28/// // and is associated with GNSS positions 5-12 in the input data
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct AssociatedNetElement {
34    /// ID of the netelement (track segment)
35    pub netelement_id: String,
36
37    /// Aggregate probability score for this segment in the path (0.0 to 1.0)
38    /// Calculated from distance/heading probability and coverage correction
39    pub probability: f64,
40
41    /// Intrinsic coordinate where the path enters this segment (0.0 to 1.0)
42    /// 0.0 = start of segment, 1.0 = end of segment
43    pub start_intrinsic: f64,
44
45    /// Intrinsic coordinate where the path exits this segment (0.0 to 1.0)
46    pub end_intrinsic: f64,
47
48    /// Index of the first GNSS position associated with this segment
49    pub gnss_start_index: usize,
50
51    /// Index of the last GNSS position associated with this segment
52    pub gnss_end_index: usize,
53
54    /// Whether this segment was placed by the algorithm or manually added by a user.
55    /// Defaults to [`PathOrigin::Algorithm`] for backward compatibility with older path files
56    /// that do not carry this field.
57    #[serde(default)]
58    pub origin: PathOrigin,
59}
60
61impl AssociatedNetElement {
62    /// Create a new associated netelement with validation
63    pub fn new(
64        netelement_id: String,
65        probability: f64,
66        start_intrinsic: f64,
67        end_intrinsic: f64,
68        gnss_start_index: usize,
69        gnss_end_index: usize,
70    ) -> Result<Self, ProjectionError> {
71        let element = Self {
72            netelement_id,
73            probability,
74            start_intrinsic,
75            end_intrinsic,
76            gnss_start_index,
77            gnss_end_index,
78            origin: PathOrigin::Algorithm,
79        };
80
81        element.validate()?;
82        Ok(element)
83    }
84
85    /// Validate associated netelement fields
86    fn validate(&self) -> Result<(), ProjectionError> {
87        // Netelement ID must be non-empty
88        if self.netelement_id.is_empty() {
89            return Err(ProjectionError::InvalidGeometry(
90                "AssociatedNetElement netelement_id must not be empty".to_string(),
91            ));
92        }
93
94        // Probability must be in [0, 1]
95        if !(0.0..=1.0).contains(&self.probability) {
96            return Err(ProjectionError::InvalidGeometry(format!(
97                "Probability must be in [0, 1], got {}",
98                self.probability
99            )));
100        }
101
102        // Intrinsic coordinates must be in [0, 1]
103        if !(0.0..=1.0).contains(&self.start_intrinsic) {
104            return Err(ProjectionError::InvalidGeometry(format!(
105                "start_intrinsic must be in [0, 1], got {}",
106                self.start_intrinsic
107            )));
108        }
109
110        if !(0.0..=1.0).contains(&self.end_intrinsic) {
111            return Err(ProjectionError::InvalidGeometry(format!(
112                "end_intrinsic must be in [0, 1], got {}",
113                self.end_intrinsic
114            )));
115        }
116
117        // Start index must be <= end index
118        if self.gnss_start_index > self.gnss_end_index {
119            return Err(ProjectionError::InvalidGeometry(format!(
120                "gnss_start_index ({}) must be <= gnss_end_index ({})",
121                self.gnss_start_index, self.gnss_end_index
122            )));
123        }
124
125        Ok(())
126    }
127
128    /// Calculate length of path segment as fraction of total netelement
129    pub fn fractional_length(&self) -> f64 {
130        (self.end_intrinsic - self.start_intrinsic).abs()
131    }
132
133    /// Calculate the fractional coverage of this segment (0.0 to 1.0)
134    /// Same as fractional_length, representing what portion of the netelement is covered
135    pub fn fractional_coverage(&self) -> f64 {
136        self.fractional_length()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_associated_netelement_valid() {
146        let segment = AssociatedNetElement::new("NE_A".to_string(), 0.87, 0.25, 0.78, 5, 12);
147
148        assert!(segment.is_ok());
149        let seg = segment.unwrap();
150        assert_eq!(seg.fractional_length(), 0.53);
151    }
152
153    #[test]
154    fn test_associated_netelement_invalid_indices() {
155        let segment = AssociatedNetElement::new(
156            "NE_A".to_string(),
157            0.87,
158            0.25,
159            0.78,
160            15, // Invalid: start > end
161            12,
162        );
163
164        assert!(segment.is_err());
165    }
166
167    /// Backward-compatibility guard: deserialising a JSON row **without** an `origin` field
168    /// must produce `PathOrigin::Algorithm` (via `#[serde(default)]`).
169    /// This ensures existing path files produced before the `origin` field was introduced
170    /// can still be loaded without errors.
171    #[test]
172    fn test_origin_defaults_to_algorithm_when_missing() {
173        let json = r#"{
174            "netelement_id": "NE_A",
175            "probability": 0.87,
176            "start_intrinsic": 0.25,
177            "end_intrinsic": 0.78,
178            "gnss_start_index": 5,
179            "gnss_end_index": 12
180        }"#;
181
182        let segment: AssociatedNetElement =
183            serde_json::from_str(json).expect("deserialization must succeed");
184        assert_eq!(
185            segment.origin,
186            PathOrigin::Algorithm,
187            "missing origin field must default to Algorithm"
188        );
189    }
190
191    #[test]
192    fn test_origin_manual_roundtrip() {
193        let json = r#"{
194            "netelement_id": "NE_B",
195            "probability": 1.0,
196            "start_intrinsic": 0.0,
197            "end_intrinsic": 1.0,
198            "gnss_start_index": 0,
199            "gnss_end_index": 0,
200            "origin": "manual"
201        }"#;
202
203        let segment: AssociatedNetElement =
204            serde_json::from_str(json).expect("deserialization must succeed");
205        assert_eq!(segment.origin, PathOrigin::Manual);
206
207        let serialised = serde_json::to_string(&segment).expect("serialization must succeed");
208        assert!(
209            serialised.contains(r#""origin":"manual""#),
210            "manual origin must serialise as lowercase"
211        );
212    }
213}