tp_lib_core/path/probability.rs
1//! Probability calculation module
2//!
3//! Implements exponential decay probability formulas for distance and heading,
4//! and transition probability for HMM-based map matching.
5
6/// Calculate probability based on distance using exponential decay.
7///
8/// # Arguments
9///
10/// * `distance_meters` - Distance from GNSS position to closest point on segment (meters)
11/// * `distance_scale` - Decay scale parameter (meters). At distance = scale, probability ≈ 0.368
12///
13/// # Returns
14///
15/// Probability value in range [0, 1]
16///
17/// # Examples
18///
19/// ```ignore
20/// let p = calculate_distance_probability(50.0, 100.0);
21/// assert!((p - 0.6065).abs() < 0.001); // exp(-0.5) ≈ 0.6065
22///
23/// let p_at_scale = calculate_distance_probability(100.0, 100.0);
24/// assert!((p_at_scale - 0.368).abs() < 0.001); // exp(-1.0) ≈ 0.368
25/// ```
26pub fn calculate_distance_probability(distance_meters: f64, distance_scale: f64) -> f64 {
27 if distance_scale <= 0.0 {
28 if distance_meters <= 0.0 {
29 1.0
30 } else {
31 0.0
32 }
33 } else {
34 (-distance_meters / distance_scale).exp()
35 }
36}
37
38/// Calculate probability based on heading difference using exponential decay.
39///
40/// # Arguments
41///
42/// * `heading_difference_degrees` - Absolute angular difference between GNSS heading and segment heading (degrees)
43/// * `heading_scale` - Decay scale parameter (degrees). At diff = scale, probability ≈ 0.368
44/// * `heading_cutoff_degrees` - Maximum heading difference to accept (degrees). Above this, probability = 0
45///
46/// # Returns
47///
48/// Probability value in range [0, 1]. Returns 0 if difference exceeds cutoff.
49///
50/// # Examples
51///
52/// ```ignore
53/// let p = calculate_heading_probability(30.0, 45.0, 90.0);
54/// assert!((p - (-30.0 / 45.0).exp()).abs() < 0.001); // exp(-0.667) ≈ 0.513
55///
56/// let p_rejected = calculate_heading_probability(100.0, 45.0, 90.0);
57/// assert_eq!(p_rejected, 0.0); // exceeds cutoff
58/// ```
59pub fn calculate_heading_probability(
60 heading_difference_degrees: f64,
61 heading_scale: f64,
62 heading_cutoff_degrees: f64,
63) -> f64 {
64 if heading_difference_degrees > heading_cutoff_degrees {
65 0.0
66 } else if heading_scale <= 0.0 {
67 if heading_difference_degrees <= 0.0 {
68 1.0
69 } else {
70 0.0
71 }
72 } else {
73 (-heading_difference_degrees / heading_scale).exp()
74 }
75}
76
77/// Calculate combined probability from distance and heading components.
78///
79/// Assumes independence, so combined probability is the product of components.
80///
81/// # Arguments
82///
83/// * `distance_probability` - Probability from distance (0-1)
84/// * `heading_probability` - Probability from heading (0-1)
85///
86/// # Returns
87///
88/// Combined probability in range [0, 1]
89///
90/// # Examples
91///
92/// ```ignore
93/// let combined = calculate_combined_probability(0.8, 0.9);
94/// assert!((combined - 0.72).abs() < 0.001); // 0.8 * 0.9 = 0.72
95/// ```
96pub fn calculate_combined_probability(distance_probability: f64, heading_probability: f64) -> f64 {
97 distance_probability * heading_probability
98}
99
100/// Calculate transition probability between two map-matched candidates
101/// using the Newson & Krumm (2009) formula.
102///
103/// The transition probability penalises candidates whose shortest-path
104/// distance through the network deviates from the great-circle distance
105/// between the two GNSS observations.
106///
107/// # Formula
108///
109/// `exp(-|route_distance - great_circle_distance| / beta)`
110///
111/// # Arguments
112///
113/// * `route_distance` - Shortest-path distance through the network (meters)
114/// * `great_circle_distance` - Great-circle (haversine) distance between the two GNSS positions (meters)
115/// * `beta` - Scale parameter controlling tolerance for route/GC mismatch (meters)
116///
117/// # Returns
118///
119/// Probability in (0, 1]. Returns 1.0 when route distance equals great-circle distance.
120pub fn calculate_transition_probability(
121 route_distance: f64,
122 great_circle_distance: f64,
123 beta: f64,
124) -> f64 {
125 if beta <= 0.0 {
126 // Degenerate: only exact matches get probability 1
127 if (route_distance - great_circle_distance).abs() < 1e-9 {
128 1.0
129 } else {
130 0.0
131 }
132 } else {
133 (-(route_distance - great_circle_distance).abs() / beta).exp()
134 }
135}
136
137/// Check whether a candidate's projected point is near a netelement endpoint.
138///
139/// Computes the haversine distance from the projected point to the nearest
140/// geometric endpoint of the netelement. If that distance is within
141/// `edge_zone_distance` meters, the candidate is "near the edge" and may
142/// transition to an adjacent netelement.
143///
144/// Used as an optimization: candidates that are well inside a netelement
145/// (far from both endpoints) cannot plausibly transition to a different
146/// netelement, so Dijkstra routing can be skipped for them.
147pub fn is_near_netelement_edge(
148 projected_point: &geo::Point<f64>,
149 netelement_geometry: &geo::LineString<f64>,
150 edge_zone_distance: f64,
151) -> bool {
152 use geo::HaversineDistance;
153
154 let coords = netelement_geometry.coords().collect::<Vec<_>>();
155 if coords.is_empty() {
156 return true; // Degenerate — treat as edge
157 }
158
159 let start = geo::Point::new(coords[0].x, coords[0].y);
160 let end = geo::Point::new(coords[coords.len() - 1].x, coords[coords.len() - 1].y);
161
162 let dist_to_start = projected_point.haversine_distance(&start);
163 let dist_to_end = projected_point.haversine_distance(&end);
164
165 dist_to_start.min(dist_to_end) <= edge_zone_distance
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn test_distance_probability_at_scale() {
174 // At distance = scale, probability should be exp(-1) ≈ 0.368
175 let p = calculate_distance_probability(100.0, 100.0);
176 assert!((p - 0.36787944).abs() < 0.0001);
177 }
178
179 #[test]
180 fn test_distance_probability_zero_distance() {
181 // At zero distance, probability should be 1.0
182 let p = calculate_distance_probability(0.0, 100.0);
183 assert_eq!(p, 1.0);
184 }
185
186 #[test]
187 fn test_distance_probability_large_distance() {
188 // Large distance should give small probability
189 let p = calculate_distance_probability(1000.0, 100.0);
190 assert!(p < 0.001);
191 }
192
193 #[test]
194 fn test_heading_probability_at_scale() {
195 // At heading_diff = scale, probability should be exp(-1) ≈ 0.368
196 let p = calculate_heading_probability(45.0, 45.0, 90.0);
197 assert!((p - 0.36787944).abs() < 0.0001);
198 }
199
200 #[test]
201 fn test_heading_probability_zero_difference() {
202 // At zero heading difference, probability should be 1.0
203 let p = calculate_heading_probability(0.0, 45.0, 90.0);
204 assert_eq!(p, 1.0);
205 }
206
207 #[test]
208 fn test_heading_probability_exceeds_cutoff() {
209 // Beyond cutoff, probability should be 0
210 let p = calculate_heading_probability(100.0, 45.0, 90.0);
211 assert_eq!(p, 0.0);
212 }
213
214 #[test]
215 fn test_heading_probability_at_cutoff_boundary() {
216 // At exactly the cutoff, should be accepted (not > cutoff)
217 let p = calculate_heading_probability(90.0, 45.0, 90.0);
218 assert!(p > 0.0); // Should be accepted
219 let p_exact = calculate_heading_probability(90.0, 45.0, 90.0);
220 let expected = (-90.0_f64 / 45.0_f64).exp();
221 assert!((p_exact - expected).abs() < 0.0001);
222 }
223
224 #[test]
225 fn test_combined_probability() {
226 // Combined should be product of components
227 let combined = calculate_combined_probability(0.8, 0.75);
228 assert!((combined - 0.6).abs() < 0.0001); // 0.8 * 0.75 = 0.6
229 }
230
231 #[test]
232 fn test_combined_probability_zero_component() {
233 // If any component is zero, combined should be zero
234 let combined = calculate_combined_probability(0.8, 0.0);
235 assert_eq!(combined, 0.0);
236 }
237
238 #[test]
239 fn test_transition_probability_perfect_match() {
240 // route == gc → exp(0) = 1.0
241 let p = calculate_transition_probability(100.0, 100.0, 50.0);
242 assert!((p - 1.0).abs() < 1e-9);
243 }
244
245 #[test]
246 fn test_transition_probability_at_beta() {
247 // |route - gc| = beta → exp(-1) ≈ 0.368
248 let p = calculate_transition_probability(150.0, 100.0, 50.0);
249 assert!((p - 0.36787944).abs() < 0.0001);
250 }
251
252 #[test]
253 fn test_transition_probability_large_mismatch() {
254 // Large mismatch → very small probability
255 let p = calculate_transition_probability(500.0, 100.0, 50.0);
256 assert!(p < 0.001);
257 }
258
259 #[test]
260 fn test_transition_probability_symmetric() {
261 // route < gc should give same result as route > gc with same |diff|
262 let p1 = calculate_transition_probability(80.0, 100.0, 50.0);
263 let p2 = calculate_transition_probability(120.0, 100.0, 50.0);
264 assert!((p1 - p2).abs() < 1e-9);
265 }
266
267 #[test]
268 fn test_near_edge_at_start() {
269 // Point near the first coordinate of the line
270 let line = geo::LineString::from(vec![(3.0, 50.0), (3.001, 50.0), (3.002, 50.0)]);
271 let point = geo::Point::new(3.0001, 50.0);
272 assert!(is_near_netelement_edge(&point, &line, 50.0));
273 }
274
275 #[test]
276 fn test_near_edge_at_end() {
277 // Point near the last coordinate of the line
278 let line = geo::LineString::from(vec![(3.0, 50.0), (3.001, 50.0), (3.002, 50.0)]);
279 let point = geo::Point::new(3.0019, 50.0);
280 assert!(is_near_netelement_edge(&point, &line, 50.0));
281 }
282
283 #[test]
284 fn test_not_near_edge_interior() {
285 // Point in the middle of a long enough line — not near either endpoint
286 // 3.0 to 3.01 ≈ ~715m at lat 50, so midpoint is ~357m from each end
287 let line = geo::LineString::from(vec![(3.0, 50.0), (3.005, 50.0), (3.01, 50.0)]);
288 let point = geo::Point::new(3.005, 50.0);
289 assert!(!is_near_netelement_edge(&point, &line, 50.0));
290 }
291
292 #[test]
293 fn test_near_edge_empty_geometry() {
294 // Degenerate geometry — should return true (safe default)
295 let line = geo::LineString::from(Vec::<(f64, f64)>::new());
296 let point = geo::Point::new(3.0, 50.0);
297 assert!(is_near_netelement_edge(&point, &line, 50.0));
298 }
299}