Skip to main content

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}