Skip to main content

tp_lib_core/models/
gnss_net_element_link.rs

1//! Link between a GNSS position and a candidate netelement
2
3use crate::errors::ProjectionError;
4use geo::Point;
5use serde::{Deserialize, Serialize};
6
7/// Link between a GNSS position and a candidate netelement
8///
9/// Created during path calculation to evaluate which netelements are potential
10/// matches for each GNSS position. Multiple links exist per GNSS position.
11/// This is an **intermediate calculation model**, not part of final output.
12///
13/// # Examples
14///
15/// ```
16/// use tp_lib_core::GnssNetElementLink;
17/// use geo::Point;
18///
19/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
20/// let link = GnssNetElementLink::new(
21///     5,
22///     "NE_A".to_string(),
23///     Point::new(4.3517, 50.8503),
24///     3.2,
25///     0.45,
26///     Some(5.3),
27///     0.89,
28/// )?;
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct GnssNetElementLink {
34    /// Index of the GNSS position in the input data
35    pub gnss_index: usize,
36
37    /// ID of the candidate netelement
38    pub netelement_id: String,
39
40    /// Projected point on the netelement (closest point to GNSS position)
41    pub projected_point: Point<f64>,
42
43    /// Distance from GNSS position to projected point in meters
44    pub distance_meters: f64,
45
46    /// Intrinsic coordinate on the netelement (0.0 to 1.0)
47    /// 0.0 = start of segment, 1.0 = end of segment
48    pub intrinsic_coordinate: f64,
49
50    /// Angular difference between GNSS heading and netelement direction (degrees)
51    /// None if GNSS position has no heading information
52    pub heading_difference: Option<f64>,
53
54    /// Probability score for this link (0.0 to 1.0)
55    /// Calculated from distance and heading probability
56    pub probability: f64,
57}
58
59impl GnssNetElementLink {
60    /// Create a new GNSS-netelement link with validation
61    pub fn new(
62        gnss_index: usize,
63        netelement_id: String,
64        projected_point: Point<f64>,
65        distance_meters: f64,
66        intrinsic_coordinate: f64,
67        heading_difference: Option<f64>,
68        probability: f64,
69    ) -> Result<Self, ProjectionError> {
70        let link = Self {
71            gnss_index,
72            netelement_id,
73            projected_point,
74            distance_meters,
75            intrinsic_coordinate,
76            heading_difference,
77            probability,
78        };
79
80        link.validate()?;
81        Ok(link)
82    }
83
84    /// Validate link fields
85    fn validate(&self) -> Result<(), ProjectionError> {
86        // Netelement ID must be non-empty
87        if self.netelement_id.is_empty() {
88            return Err(ProjectionError::InvalidGeometry(
89                "GnssNetElementLink netelement_id must not be empty".to_string(),
90            ));
91        }
92
93        // Distance must be non-negative
94        if self.distance_meters < 0.0 {
95            return Err(ProjectionError::InvalidGeometry(format!(
96                "distance_meters must be non-negative, got {}",
97                self.distance_meters
98            )));
99        }
100
101        // Intrinsic coordinate must be in [0, 1]
102        if !(0.0..=1.0).contains(&self.intrinsic_coordinate) {
103            return Err(ProjectionError::InvalidGeometry(format!(
104                "intrinsic_coordinate must be in [0, 1], got {}",
105                self.intrinsic_coordinate
106            )));
107        }
108
109        // Heading difference must be in [0, 180] if present
110        if let Some(heading_diff) = self.heading_difference {
111            if !(0.0..=180.0).contains(&heading_diff) {
112                return Err(ProjectionError::InvalidGeometry(format!(
113                    "heading_difference must be in [0, 180], got {}",
114                    heading_diff
115                )));
116            }
117        }
118
119        // Probability must be in [0, 1]
120        if !(0.0..=1.0).contains(&self.probability) {
121            return Err(ProjectionError::InvalidGeometry(format!(
122                "Probability must be in [0, 1], got {}",
123                self.probability
124            )));
125        }
126
127        Ok(())
128    }
129
130    /// Check if this is a high-probability candidate (>= threshold)
131    pub fn is_high_probability(&self, threshold: f64) -> bool {
132        self.probability >= threshold
133    }
134
135    /// Check if distance is within acceptable range
136    pub fn is_within_distance(&self, max_distance_meters: f64) -> bool {
137        self.distance_meters <= max_distance_meters
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_gnss_link_valid() {
147        let link = GnssNetElementLink::new(
148            5,
149            "NE_A".to_string(),
150            Point::new(4.3517, 50.8503),
151            3.2,
152            0.45,
153            Some(5.3),
154            0.89,
155        );
156
157        assert!(link.is_ok());
158    }
159
160    #[test]
161    fn test_gnss_link_invalid_probability() {
162        let link = GnssNetElementLink::new(
163            5,
164            "NE_A".to_string(),
165            Point::new(4.3517, 50.8503),
166            3.2,
167            0.45,
168            Some(5.3),
169            1.5, // Invalid: > 1.0
170        );
171
172        assert!(link.is_err());
173    }
174
175    #[test]
176    fn test_gnss_link_is_high_probability() {
177        let link = GnssNetElementLink::new(
178            5,
179            "NE_A".to_string(),
180            Point::new(4.3517, 50.8503),
181            3.2,
182            0.45,
183            Some(5.3),
184            0.89,
185        )
186        .unwrap();
187
188        assert!(link.is_high_probability(0.8));
189        assert!(link.is_high_probability(0.89));
190        assert!(!link.is_high_probability(0.9));
191    }
192
193    #[test]
194    fn test_gnss_link_is_within_distance() {
195        let link = GnssNetElementLink::new(
196            5,
197            "NE_A".to_string(),
198            Point::new(4.3517, 50.8503),
199            3.2,
200            0.45,
201            Some(5.3),
202            0.89,
203        )
204        .unwrap();
205
206        assert!(link.is_within_distance(5.0));
207        assert!(link.is_within_distance(3.2));
208        assert!(!link.is_within_distance(3.0));
209    }
210
211    #[test]
212    fn test_gnss_link_negative_distance() {
213        let link = GnssNetElementLink::new(
214            5,
215            "NE_A".to_string(),
216            Point::new(4.3517, 50.8503),
217            -1.0, // Invalid: negative
218            0.45,
219            Some(5.3),
220            0.89,
221        );
222
223        assert!(link.is_err());
224    }
225
226    #[test]
227    fn test_gnss_link_invalid_intrinsic() {
228        let link = GnssNetElementLink::new(
229            5,
230            "NE_A".to_string(),
231            Point::new(4.3517, 50.8503),
232            3.2,
233            1.5, // Invalid: > 1.0
234            Some(5.3),
235            0.89,
236        );
237
238        assert!(link.is_err());
239    }
240
241    #[test]
242    fn test_gnss_link_invalid_heading_difference() {
243        let link = GnssNetElementLink::new(
244            5,
245            "NE_A".to_string(),
246            Point::new(4.3517, 50.8503),
247            3.2,
248            0.45,
249            Some(200.0), // Invalid: > 180
250            0.89,
251        );
252
253        assert!(link.is_err());
254    }
255
256    #[test]
257    fn test_gnss_link_no_heading_difference() {
258        let link = GnssNetElementLink::new(
259            5,
260            "NE_A".to_string(),
261            Point::new(4.3517, 50.8503),
262            3.2,
263            0.45,
264            None, // Valid: no heading info
265            0.89,
266        );
267
268        assert!(link.is_ok());
269    }
270
271    #[test]
272    fn test_gnss_link_empty_netelement_id() {
273        let link = GnssNetElementLink::new(
274            5,
275            "".to_string(), // Invalid: empty
276            Point::new(4.3517, 50.8503),
277            3.2,
278            0.45,
279            Some(5.3),
280            0.89,
281        );
282
283        assert!(link.is_err());
284    }
285}