tp_lib_core/models/
associated_net_element.rs1use crate::errors::ProjectionError;
4use crate::models::PathOrigin;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
33pub struct AssociatedNetElement {
34 pub netelement_id: String,
36
37 pub probability: f64,
40
41 pub start_intrinsic: f64,
44
45 pub end_intrinsic: f64,
47
48 pub gnss_start_index: usize,
50
51 pub gnss_end_index: usize,
53
54 #[serde(default)]
58 pub origin: PathOrigin,
59}
60
61impl AssociatedNetElement {
62 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 fn validate(&self) -> Result<(), ProjectionError> {
87 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 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 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 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 pub fn fractional_length(&self) -> f64 {
130 (self.end_intrinsic - self.start_intrinsic).abs()
131 }
132
133 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, 12,
162 );
163
164 assert!(segment.is_err());
165 }
166
167 #[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}