Skip to main content

tp_lib_core/path/
candidate.rs

1//! Candidate netelement selection for GNSS positions
2//!
3//! Finds candidate netelements that could match each GNSS position based on
4//! spatial proximity and heading alignment.
5
6use crate::errors::ProjectionError;
7use crate::models::{GnssPosition, Netelement};
8use geo::{LineString, Point};
9
10/// Candidates with intrinsic coordinate closer than this to 0.0 or 1.0 are
11/// rejected.  Projections at the geometric endpoints indicate the GNSS point
12/// is more likely located on an adjacent netelement.
13const EDGE_INTRINSIC_THRESHOLD: f64 = 1e-6;
14
15/// A candidate netelement for a GNSS position
16#[derive(Debug, Clone)]
17pub struct CandidateNetElement {
18    /// ID of the candidate netelement
19    pub netelement_id: String,
20
21    /// Distance from GNSS position to closest point on linestring (meters)
22    pub distance_meters: f64,
23
24    /// Intrinsic coordinate on the netelement (0.0 to 1.0)
25    /// where 0.0 = start of segment, 1.0 = end of segment
26    pub intrinsic_coordinate: f64,
27
28    /// Projected point on the netelement
29    pub projected_point: Point<f64>,
30}
31
32/// Find candidate netelements for a GNSS position
33///
34/// Returns netelements within cutoff_distance, sorted by distance.
35///
36/// # Arguments
37///
38/// * `gnss_pos` - The GNSS position to find candidates for
39/// * `netelements` - All available network netelements
40/// * `cutoff_distance` - Maximum distance for candidate inclusion (meters)
41/// * `max_candidates` - Maximum number of candidates to return
42///
43/// # Returns
44///
45/// Vector of candidate netelements sorted by distance (closest first)
46pub fn find_candidate_netelements(
47    gnss_pos: &GnssPosition,
48    netelements: &[Netelement],
49    cutoff_distance: f64,
50    max_candidates: usize,
51) -> Result<Vec<CandidateNetElement>, ProjectionError> {
52    let gnss_point = Point::new(gnss_pos.longitude, gnss_pos.latitude);
53    let mut candidates = Vec::new();
54
55    // Search all netelements for candidates within cutoff
56    for netelement in netelements {
57        // Calculate distance to netelement
58        let (distance, intrinsic, proj_point) =
59            calculate_closest_point_on_linestring(&gnss_point, &netelement.geometry)?;
60
61        // Include if within cutoff distance
62        if distance <= cutoff_distance {
63            candidates.push(CandidateNetElement {
64                netelement_id: netelement.id.clone(),
65                distance_meters: distance,
66                intrinsic_coordinate: intrinsic,
67                projected_point: proj_point,
68            });
69        }
70    }
71
72    // Prefer interior projections over edge projections (intrinsic at 0 or 1).
73    // Edge projections indicate the GNSS point is past the netelement boundary
74    // and may belong to an adjacent netelement.  If at least one non-edge
75    // candidate exists, remove edge candidates.
76    //
77    // Fallback: if *all* candidates are edge projections, none are removed.
78    // This prevents the position from having zero candidates when the GNSS
79    // point sits exactly at a netelement connection boundary.
80    let has_interior = candidates.iter().any(|c| {
81        c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
82            && c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
83    });
84    if has_interior {
85        candidates.retain(|c| {
86            c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
87                && c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
88        });
89    }
90
91    // Sort by distance (closest first)
92    candidates.sort_by(|a, b| a.distance_meters.partial_cmp(&b.distance_meters).unwrap());
93
94    // Return top max_candidates
95    candidates.truncate(max_candidates);
96
97    Ok(candidates)
98}
99
100/// Calculate closest point on a linestring to a point
101///
102/// Returns (distance, intrinsic_coordinate, projected_point)
103///
104/// # Arguments
105///
106/// * `point` - The reference point (GNSS position)
107/// * `linestring` - The linestring to find closest point on
108///
109/// # Returns
110///
111/// Tuple of:
112/// - distance: Distance from point to closest point on linestring (meters)
113/// - intrinsic: Intrinsic coordinate on linestring (0.0 to 1.0)
114/// - projected_point: The closest point on the linestring
115fn calculate_closest_point_on_linestring(
116    point: &Point<f64>,
117    linestring: &LineString<f64>,
118) -> Result<(f64, f64, Point<f64>), ProjectionError> {
119    use geo::algorithm::haversine_distance::HaversineDistance;
120
121    let coords = &linestring.0;
122
123    // Use cos(lat) scaling so the dot-product projection is metrically
124    // correct for geographic (WGS 84) coordinates — same approach used by
125    // `calculate_heading_at_point` and `project_point_onto_linestring`.
126    let cos_lat = point.y().to_radians().cos();
127
128    let mut min_dist_sq = f64::INFINITY;
129    let mut best_seg: usize = 0;
130    let mut best_t: f64 = 0.0;
131    let mut closest_point = coords[0];
132
133    for i in 0..coords.len() - 1 {
134        let p1 = &coords[i];
135        let p2 = &coords[i + 1];
136
137        let dx = (p2.x - p1.x) * cos_lat;
138        let dy = p2.y - p1.y;
139        let len_sq = dx * dx + dy * dy;
140
141        let t = if len_sq > 0.0 {
142            let px = (point.x() - p1.x) * cos_lat;
143            let py = point.y() - p1.y;
144            ((px * dx + py * dy) / len_sq).clamp(0.0, 1.0)
145        } else {
146            0.0
147        };
148
149        // Interpolate back in degree space.
150        let proj_x = p1.x + t * (p2.x - p1.x);
151        let proj_y = p1.y + t * (p2.y - p1.y);
152
153        let ex = (point.x() - proj_x) * cos_lat;
154        let ey = point.y() - proj_y;
155        let dist_sq = ex * ex + ey * ey;
156
157        if dist_sq < min_dist_sq {
158            min_dist_sq = dist_sq;
159            closest_point = geo::Coord {
160                x: proj_x,
161                y: proj_y,
162            };
163            best_seg = i;
164            best_t = t;
165        }
166    }
167
168    let closest_pt = Point::new(closest_point.x, closest_point.y);
169
170    // Distance in meters via haversine (accurate for any latitude).
171    let distance = point.haversine_distance(&closest_pt);
172
173    // Compute length-based intrinsic coordinate (0..1) using haversine
174    // segment lengths instead of a uniform vertex-index parameterization.
175    let mut length_before = 0.0;
176    for i in 0..best_seg {
177        let a = Point::new(coords[i].x, coords[i].y);
178        let b = Point::new(coords[i + 1].x, coords[i + 1].y);
179        length_before += a.haversine_distance(&b);
180    }
181    let seg_start = Point::new(coords[best_seg].x, coords[best_seg].y);
182    let seg_end = Point::new(coords[best_seg + 1].x, coords[best_seg + 1].y);
183    let seg_length = seg_start.haversine_distance(&seg_end);
184    length_before += best_t * seg_length;
185
186    let total_length: f64 = (0..coords.len() - 1)
187        .map(|i| {
188            let a = Point::new(coords[i].x, coords[i].y);
189            let b = Point::new(coords[i + 1].x, coords[i + 1].y);
190            a.haversine_distance(&b)
191        })
192        .sum();
193
194    let intrinsic = if total_length > 0.0 {
195        (length_before / total_length).clamp(0.0, 1.0)
196    } else {
197        0.0
198    };
199
200    Ok((distance, intrinsic, closest_pt))
201}
202
203/// Calculate heading at a projected point on a linestring
204///
205/// Returns the direction (bearing) of the linestring at the given point.
206/// This is used to compare with GNSS heading to filter incompatible candidates.
207///
208/// # Arguments
209///
210/// * `point` - The point on the linestring
211/// * `linestring` - The linestring
212///
213/// # Returns
214///
215/// Heading in degrees (0-360), where:
216/// - 0° = North
217/// - 90° = East
218/// - 180° = South
219/// - 270° = West
220pub fn calculate_heading_at_point(
221    point: &Point<f64>,
222    linestring: &LineString<f64>,
223) -> Result<f64, ProjectionError> {
224    let coords: Vec<_> = linestring.points().collect();
225
226    if coords.len() < 2 {
227        return Ok(0.0);
228    }
229
230    // Find the segment closest to the point by perpendicular (point-to-segment)
231    // distance, not just distance to the start vertex.
232    let cos_lat = point.y().to_radians().cos();
233    let px = point.x() * 111_320.0 * cos_lat;
234    let py = point.y() * 111_320.0;
235
236    let mut closest_segment_idx = 0;
237    let mut closest_distance = f64::MAX;
238
239    for i in 0..coords.len() - 1 {
240        let ax = coords[i].x() * 111_320.0 * cos_lat;
241        let ay = coords[i].y() * 111_320.0;
242        let bx = coords[i + 1].x() * 111_320.0 * cos_lat;
243        let by = coords[i + 1].y() * 111_320.0;
244
245        let dx = bx - ax;
246        let dy = by - ay;
247        let seg_len_sq = dx * dx + dy * dy;
248
249        // Project point onto the segment, clamped to [0, 1].
250        let t = if seg_len_sq < 1e-18 {
251            0.0
252        } else {
253            ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
254        }
255        .clamp(0.0, 1.0);
256
257        let proj_x = ax + t * dx;
258        let proj_y = ay + t * dy;
259        let dist = ((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt();
260
261        if dist < closest_distance {
262            closest_distance = dist;
263            closest_segment_idx = i;
264        }
265    }
266
267    // Get segment direction using haversine bearing (correctly accounts
268    // for the cos(latitude) scaling of longitude at any latitude).
269    let p1 = coords[closest_segment_idx];
270    let p2 = coords[closest_segment_idx + 1];
271
272    Ok(haversine_bearing(
273        &Point::new(p1.x(), p1.y()),
274        &Point::new(p2.x(), p2.y()),
275    ))
276}
277
278/// Estimate headings from neighboring GNSS positions.
279///
280/// For each interior position `x`, computes the haversine bearing from position
281/// `x-1` to `x+1`.  The estimate is discarded when:
282/// - `x` is the first or last position (no symmetric neighbors),
283/// - the distances `x-1 → x` and `x → x+1` differ by ≥ 20% of the larger,
284/// - the backward bearing (`x-1` → `x`) and forward bearing (`x` → `x+1`)
285///   diverge by ≥ 5° (lateral-jump / junction guard),
286/// - consecutive estimated headings change by ≥ 5° (continuity check).
287///
288/// Returns a `Vec` with the same length as `positions`; entries that fail any
289/// guard are `None`.
290pub fn estimate_headings_from_neighbors(positions: &[&GnssPosition]) -> Vec<Option<f64>> {
291    let n = positions.len();
292    let mut headings: Vec<Option<f64>> = vec![None; n];
293
294    if n < 3 {
295        return headings;
296    }
297
298    // Pass 1: compute raw estimated headings for interior positions.
299    for x in 1..n - 1 {
300        let prev = Point::new(positions[x - 1].longitude, positions[x - 1].latitude);
301        let curr = Point::new(positions[x].longitude, positions[x].latitude);
302        let next = Point::new(positions[x + 1].longitude, positions[x + 1].latitude);
303
304        let d_prev = haversine_distance(&prev, &curr);
305        let d_next = haversine_distance(&curr, &next);
306
307        // Distance symmetry guard: reject if ratio difference ≥ 20%.
308        let max_d = d_prev.max(d_next);
309        if max_d < 1e-9 {
310            continue; // degenerate (coincident points)
311        }
312        if (d_prev - d_next).abs() / max_d >= 0.20 {
313            continue;
314        }
315
316        // Bearing deviation guard: the backward bearing (x-1 → x) and the
317        // forward bearing (x → x+1) should agree on a stable trajectory.
318        // A divergence ≥ 5° indicates a lateral GNSS jump or a junction turn;
319        // in either case the smoothed heading is unreliable.
320        let h_back = haversine_bearing(&prev, &curr);
321        let h_fwd = haversine_bearing(&curr, &next);
322        if directional_heading_difference(h_back, h_fwd) >= 5.0 {
323            continue;
324        }
325
326        headings[x] = Some(haversine_bearing(&prev, &next));
327    }
328
329    // Pass 2: heading-continuity check (< 5° between consecutive estimates).
330    // Compare each heading against its immediate neighbor's raw (pre-rejection)
331    // heading to avoid cascading rejections.
332    let raw_headings = headings.clone();
333    for x in 2..n - 1 {
334        if let Some(h) = headings[x] {
335            if let Some(prev_h) = raw_headings[x - 1] {
336                if heading_difference(h, prev_h) >= 5.0 {
337                    headings[x] = None;
338                }
339            }
340        }
341    }
342
343    headings
344}
345
346/// Haversine distance between two WGS-84 points, in metres.
347fn haversine_distance(a: &Point<f64>, b: &Point<f64>) -> f64 {
348    let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
349    let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
350    let dlat = lat2 - lat1;
351    let dlon = lon2 - lon1;
352    let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
353    2.0 * 6_371_000.0 * h.sqrt().asin()
354}
355
356/// Haversine initial bearing from point `a` to point `b`, in degrees [0, 360).
357pub(crate) fn haversine_bearing(a: &Point<f64>, b: &Point<f64>) -> f64 {
358    let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
359    let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
360    let dlon = lon2 - lon1;
361    let x = dlon.sin() * lat2.cos();
362    let y = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlon.cos();
363    (x.atan2(y).to_degrees() + 360.0) % 360.0
364}
365
366/// Calculate the difference between two headings on a bidirectional track.
367///
368/// Railway tracks can be traveled in either direction, so a heading and its
369/// opposite (180° apart) are considered equivalent.  Returns a value in
370/// [0, 90] where 0 = perfectly aligned (same or opposite direction) and
371/// 90 = perpendicular.
372///
373/// # Arguments
374///
375/// * `heading1` - First heading in degrees (0-360)
376/// * `heading2` - Second heading in degrees (0-360)
377///
378/// # Returns
379///
380/// Angular difference in degrees (0-90)
381pub fn heading_difference(heading1: f64, heading2: f64) -> f64 {
382    let diff = (heading1 - heading2).abs() % 360.0;
383
384    // Smallest circular angle in [0, 180]
385    let diff = if diff > 180.0 { 360.0 - diff } else { diff };
386
387    // Bidirectional equivalence: 0° ↔ 180° are both "aligned"
388    if diff > 90.0 {
389        180.0 - diff
390    } else {
391        diff
392    }
393}
394
395/// Calculate the directional difference between two headings.
396///
397/// Unlike [`heading_difference`], this does NOT apply bidirectional
398/// equivalence: 0° and 180° are considered opposite (difference = 180°).
399/// Returns a value in [0, 180].
400///
401/// Used for turn-angle penalties at netelement connections where the direction of
402/// travel matters (the train cannot make a U-turn).
403pub(crate) fn directional_heading_difference(heading1: f64, heading2: f64) -> f64 {
404    let diff = (heading1 - heading2).abs() % 360.0;
405    if diff > 180.0 {
406        360.0 - diff
407    } else {
408        diff
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use geo::LineString;
416
417    #[test]
418    fn test_heading_difference_same_direction() {
419        let diff = heading_difference(45.0, 45.0);
420        assert_eq!(diff, 0.0);
421    }
422
423    #[test]
424    fn test_heading_difference_opposite_direction() {
425        // Opposite direction is equivalent on a bidirectional track
426        let diff = heading_difference(0.0, 180.0);
427        assert_eq!(diff, 0.0);
428    }
429
430    #[test]
431    fn test_heading_difference_wraparound() {
432        // 350° and 10° are 20° apart (not 340°)
433        let diff = heading_difference(350.0, 10.0);
434        assert_eq!(diff, 20.0);
435    }
436
437    #[test]
438    fn test_heading_difference_perpendicular() {
439        let diff = heading_difference(0.0, 90.0);
440        assert_eq!(diff, 90.0);
441    }
442
443    #[test]
444    fn test_heading_difference_near_opposite() {
445        // 170° difference from north → only 10° from the opposite direction
446        let diff = heading_difference(0.0, 170.0);
447        assert_eq!(diff, 10.0);
448    }
449
450    #[test]
451    fn test_candidate_selection_within_cutoff() {
452        // Create a simple linestring
453        let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
454        let netelement =
455            Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
456
457        // Create a GNSS position near the linestring
458        let gnss = GnssPosition::new(
459            50.8502,
460            4.3502,
461            chrono::Utc::now().into(),
462            "EPSG:4326".to_string(),
463        )
464        .unwrap();
465
466        // Find candidates
467        let candidates = find_candidate_netelements(&gnss, &[netelement], 50.0, 10).unwrap();
468
469        // Should find the netelement
470        assert_eq!(candidates.len(), 1);
471        assert_eq!(candidates[0].netelement_id, "NE_TEST");
472        assert!(candidates[0].distance_meters < 50.0);
473    }
474
475    #[test]
476    fn test_candidate_selection_beyond_cutoff() {
477        let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
478        let netelement =
479            Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
480
481        // Create a GNSS position far from the linestring
482        let gnss = GnssPosition::new(
483            50.90,
484            4.40,
485            chrono::Utc::now().into(),
486            "EPSG:4326".to_string(),
487        )
488        .unwrap();
489
490        // Find candidates with small cutoff
491        let candidates = find_candidate_netelements(&gnss, &[netelement], 1.0, 10).unwrap();
492
493        // Should not find the netelement
494        assert_eq!(candidates.len(), 0);
495    }
496
497    #[test]
498    fn test_candidate_max_candidates_limit() {
499        // Create three netelements
500        let netelements = vec![
501            Netelement::new(
502                "NE_1".to_string(),
503                LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
504                "EPSG:4326".to_string(),
505            )
506            .unwrap(),
507            Netelement::new(
508                "NE_2".to_string(),
509                LineString::from(vec![(4.3502, 50.8502), (4.3512, 50.8512)]),
510                "EPSG:4326".to_string(),
511            )
512            .unwrap(),
513            Netelement::new(
514                "NE_3".to_string(),
515                LineString::from(vec![(4.3503, 50.8503), (4.3513, 50.8513)]),
516                "EPSG:4326".to_string(),
517            )
518            .unwrap(),
519        ];
520
521        let gnss = GnssPosition::new(
522            50.8502,
523            4.3502,
524            chrono::Utc::now().into(),
525            "EPSG:4326".to_string(),
526        )
527        .unwrap();
528
529        // Find candidates with max_candidates = 2
530        let candidates = find_candidate_netelements(&gnss, &netelements, 500.0, 2).unwrap();
531
532        // Should return at most 2 candidates
533        assert!(candidates.len() <= 2);
534    }
535
536    // ── Edge rejection tests ─────────────────────────────────────────────
537
538    #[test]
539    fn test_edge_projection_at_start_kept_as_fallback() {
540        // Linestring running east.  GNSS point placed exactly at the start
541        // (lat/lon matching the first coordinate) → intrinsic ≈ 0.0.
542        // With no interior candidates, the edge candidate is kept as fallback.
543        let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
544        let netelement =
545            Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
546
547        let gnss = GnssPosition::new(
548            50.850,
549            4.350,
550            chrono::Utc::now().into(),
551            "EPSG:4326".to_string(),
552        )
553        .unwrap();
554
555        let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
556        assert_eq!(
557            candidates.len(),
558            1,
559            "Edge candidate kept as fallback when no interior candidates exist"
560        );
561    }
562
563    #[test]
564    fn test_edge_projection_at_end_kept_as_fallback() {
565        // GNSS point placed exactly at the end of the linestring → intrinsic ≈ 1.0
566        // With no interior candidates, the edge candidate is kept as fallback.
567        let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
568        let netelement =
569            Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
570
571        let gnss = GnssPosition::new(
572            50.850,
573            4.360,
574            chrono::Utc::now().into(),
575            "EPSG:4326".to_string(),
576        )
577        .unwrap();
578
579        let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
580        assert_eq!(
581            candidates.len(),
582            1,
583            "Edge candidate kept as fallback when no interior candidates exist"
584        );
585    }
586
587    #[test]
588    fn test_mid_range_projection_accepted() {
589        // GNSS point near the midpoint of the linestring → intrinsic ≈ 0.5 → accepted.
590        let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
591        let netelement =
592            Netelement::new("NE_MID".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
593
594        let gnss = GnssPosition::new(
595            50.850,
596            4.355,
597            chrono::Utc::now().into(),
598            "EPSG:4326".to_string(),
599        )
600        .unwrap();
601
602        let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
603        assert_eq!(candidates.len(), 1);
604        assert!(
605            candidates[0].intrinsic_coordinate > 0.1 && candidates[0].intrinsic_coordinate < 0.9,
606            "Midpoint candidate should have intrinsic near 0.5"
607        );
608    }
609
610    #[test]
611    fn test_edge_candidate_removed_when_interior_exists() {
612        // Two netelements: one projects to the midpoint (interior), one to its
613        // endpoint (edge).  The edge candidate is removed.
614        let ne_interior = Netelement::new(
615            "NE_INT".to_string(),
616            LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]),
617            "EPSG:4326".to_string(),
618        )
619        .unwrap();
620        let ne_edge = Netelement::new(
621            "NE_EDGE".to_string(),
622            LineString::from(vec![(4.353, 50.851), (4.355, 50.851)]),
623            "EPSG:4326".to_string(),
624        )
625        .unwrap();
626
627        // GNSS at midpoint of NE_INT, but past the end of NE_EDGE
628        let gnss = GnssPosition::new(
629            50.850,
630            4.355,
631            chrono::Utc::now().into(),
632            "EPSG:4326".to_string(),
633        )
634        .unwrap();
635
636        let candidates =
637            find_candidate_netelements(&gnss, &[ne_interior, ne_edge], 500.0, 10).unwrap();
638        assert!(
639            candidates.iter().all(|c| c.netelement_id != "NE_EDGE"
640                || (c.intrinsic_coordinate > 1e-6 && c.intrinsic_coordinate < 1.0 - 1e-6)),
641            "Edge candidates should be removed when interior candidates exist"
642        );
643    }
644
645    // ── Heading estimation tests ─────────────────────────────────────────
646
647    fn make_gnss(lat: f64, lon: f64) -> GnssPosition {
648        GnssPosition::new(lat, lon, chrono::Utc::now().into(), "EPSG:4326".to_string()).unwrap()
649    }
650
651    #[test]
652    fn test_estimate_headings_straight_north() {
653        // Three points along the same meridian heading due north, equally spaced.
654        let positions = [
655            make_gnss(50.000, 4.000),
656            make_gnss(50.001, 4.000),
657            make_gnss(50.002, 4.000),
658        ];
659        let refs: Vec<&GnssPosition> = positions.iter().collect();
660        let headings = estimate_headings_from_neighbors(&refs);
661
662        assert!(headings[0].is_none(), "First position should be None");
663        assert!(headings[2].is_none(), "Last position should be None");
664        let h = headings[1].expect("Middle position should have estimated heading");
665        // Bearing from position 0 to position 2 should be ≈ 0° (north)
666        assert!(!(1.0..=359.0).contains(&h), "Expected ~0° north, got {h}");
667    }
668
669    #[test]
670    fn test_estimate_headings_straight_east() {
671        // Three points along the same latitude heading due east, equally spaced.
672        let positions = [
673            make_gnss(50.000, 4.000),
674            make_gnss(50.000, 4.001),
675            make_gnss(50.000, 4.002),
676        ];
677        let refs: Vec<&GnssPosition> = positions.iter().collect();
678        let headings = estimate_headings_from_neighbors(&refs);
679
680        let h = headings[1].expect("Middle position should have estimated heading");
681        assert!((h - 90.0).abs() < 1.0, "Expected ~90° east, got {h}");
682    }
683
684    #[test]
685    fn test_estimate_headings_endpoints_none() {
686        let positions = [make_gnss(50.000, 4.000), make_gnss(50.001, 4.000)];
687        let refs: Vec<&GnssPosition> = positions.iter().collect();
688        let headings = estimate_headings_from_neighbors(&refs);
689        assert!(
690            headings.iter().all(|h| h.is_none()),
691            "With < 3 positions all should be None"
692        );
693    }
694
695    #[test]
696    fn test_estimate_headings_unequal_spacing() {
697        // Distance from p0→p1 is much larger than p1→p2 → ratio > 20% → None
698        let positions = [
699            make_gnss(50.000, 4.000),
700            make_gnss(50.010, 4.000),  // ~1.1 km north
701            make_gnss(50.0101, 4.000), // ~11 m further north
702        ];
703        let refs: Vec<&GnssPosition> = positions.iter().collect();
704        let headings = estimate_headings_from_neighbors(&refs);
705        assert!(
706            headings[1].is_none(),
707            "Unequal spacing should reject estimated heading"
708        );
709    }
710
711    #[test]
712    fn test_estimate_headings_continuity_rejection() {
713        // Five points: first three go north, then a sharp 90° turn east.
714        // The position after the turn should fail the 5° continuity check.
715        let positions = [
716            make_gnss(50.000, 4.000),
717            make_gnss(50.001, 4.000), // heading calc: bearing from 0→2 ≈ north
718            make_gnss(50.002, 4.000), // heading calc: bearing from 1→3 ≈ NE (sharp change)
719            make_gnss(50.002, 4.001), // heading calc: bearing from 2→4 ≈ east
720            make_gnss(50.002, 4.002),
721        ];
722        let refs: Vec<&GnssPosition> = positions.iter().collect();
723        let headings = estimate_headings_from_neighbors(&refs);
724
725        // Position 1: should have a heading (north)
726        assert!(headings[1].is_some(), "Position 1 heading should be valid");
727        // Positions 2 or 3 should have None due to the sharp turn
728        let has_rejection = headings[2].is_none() || headings[3].is_none();
729        assert!(
730            has_rejection,
731            "Sharp turn should cause at least one heading rejection"
732        );
733    }
734}