Skip to main content

tp_lib_core/detections/
anchor.rs

1//! Anchor injection into the Viterbi candidate / emission lattice (T019, T026).
2//!
3//! For each [`ResolvedAnchor`], rewrites the candidate set and emission row at
4//! the affected GNSS index/range so that the Viterbi pass is *forced* to pass
5//! through the anchored netelement at the anchored intrinsic position(s).
6//!
7//! Punctual anchors collapse `position_candidates[gnss_index]` to a single
8//! [`CandidateNetElement`] with emission probability 1.0.
9//!
10//! Linear anchors restrict every GNSS step in `gnss_range` to candidates
11//! lying on the anchored netelement (a single forced candidate per step,
12//! intrinsic linearly interpolated between `start_intrinsic` and
13//! `end_intrinsic`).
14
15use std::collections::HashMap;
16
17use geo::{LineString, Point};
18
19use crate::models::{Netelement, ResolvedAnchor};
20use crate::path::candidate::CandidateNetElement;
21
22use super::error::DetectionError;
23
24/// Apply every anchor to `position_candidates` / `emission_probs`.
25///
26/// `gnss_index_map` (when `Some`) maps the anchor's *original* GNSS index
27/// (referring to the input GNSS array) to the *working* index (after
28/// resampling). When `None`, anchors are assumed to use working indices
29/// directly.
30///
31/// Out-of-bounds anchors (`gnss_index >= position_candidates.len()`) are
32/// silently skipped — the caller's filter pass should already have removed
33/// them, but defensive checks keep us robust against resampling edge cases.
34pub fn apply_anchors(
35    anchors: &[ResolvedAnchor],
36    position_candidates: &mut [Vec<CandidateNetElement>],
37    emission_probs: &mut [Vec<f64>],
38    netelements: &[Netelement],
39    netelement_index: &HashMap<String, usize>,
40    gnss_index_map: Option<&[usize]>,
41) -> Result<(), DetectionError> {
42    debug_assert_eq!(position_candidates.len(), emission_probs.len());
43
44    for anchor in anchors {
45        match anchor {
46            ResolvedAnchor::Punctual {
47                netelement_id,
48                intrinsic,
49                gnss_index,
50            } => {
51                let Some(working_idx) = remap_index(*gnss_index, gnss_index_map) else {
52                    continue;
53                };
54                if working_idx >= position_candidates.len() {
55                    continue;
56                }
57                let Some(&ne_idx) = netelement_index.get(netelement_id) else {
58                    return Err(DetectionError::UnknownNetelement {
59                        source_file: String::new(),
60                        source_row: 0,
61                        netelement_id: netelement_id.clone(),
62                    });
63                };
64                let ne = &netelements[ne_idx];
65                let pt = point_at_intrinsic(&ne.geometry, *intrinsic);
66                let forced = CandidateNetElement {
67                    netelement_id: netelement_id.clone(),
68                    distance_meters: 0.0,
69                    intrinsic_coordinate: intrinsic.clamp(0.0, 1.0),
70                    projected_point: pt,
71                };
72                position_candidates[working_idx] = vec![forced];
73                emission_probs[working_idx] = vec![1.0];
74            }
75            ResolvedAnchor::Linear {
76                netelement_id,
77                start_intrinsic,
78                end_intrinsic,
79                gnss_range,
80            } => {
81                let Some(&ne_idx) = netelement_index.get(netelement_id) else {
82                    return Err(DetectionError::UnknownNetelement {
83                        source_file: String::new(),
84                        source_row: 0,
85                        netelement_id: netelement_id.clone(),
86                    });
87                };
88                let ne = &netelements[ne_idx];
89
90                let lo = *gnss_range.start();
91                let hi = *gnss_range.end();
92                let span = hi.saturating_sub(lo) as f64;
93
94                for original_idx in lo..=hi {
95                    let Some(working_idx) = remap_index(original_idx, gnss_index_map) else {
96                        continue;
97                    };
98                    if working_idx >= position_candidates.len() {
99                        continue;
100                    }
101
102                    let frac = if span > 0.0 {
103                        (original_idx - lo) as f64 / span
104                    } else {
105                        0.0
106                    };
107                    let intrinsic = start_intrinsic + frac * (end_intrinsic - start_intrinsic);
108                    let intrinsic = intrinsic.clamp(0.0, 1.0);
109                    let pt = point_at_intrinsic(&ne.geometry, intrinsic);
110
111                    let forced = CandidateNetElement {
112                        netelement_id: netelement_id.clone(),
113                        distance_meters: 0.0,
114                        intrinsic_coordinate: intrinsic,
115                        projected_point: pt,
116                    };
117                    position_candidates[working_idx] = vec![forced];
118                    emission_probs[working_idx] = vec![1.0];
119                }
120            }
121        }
122    }
123
124    Ok(())
125}
126
127fn remap_index(original: usize, map: Option<&[usize]>) -> Option<usize> {
128    match map {
129        None => Some(original),
130        Some(m) => m.get(original).copied(),
131    }
132}
133
134/// Interpolate a [`Point`] at fractional distance `intrinsic` ∈ [0, 1] along
135/// `line`, using haversine segment lengths so the result is metric-correct on
136/// geographic (WGS84) coordinates.
137pub(crate) fn point_at_intrinsic(line: &LineString<f64>, intrinsic: f64) -> Point<f64> {
138    use geo::algorithm::haversine_distance::HaversineDistance;
139
140    let coords = &line.0;
141    if coords.is_empty() {
142        return Point::new(0.0, 0.0);
143    }
144    if coords.len() == 1 {
145        return Point::new(coords[0].x, coords[0].y);
146    }
147
148    let t = intrinsic.clamp(0.0, 1.0);
149
150    // Per-segment & total haversine length.
151    let mut seg_lens: Vec<f64> = Vec::with_capacity(coords.len() - 1);
152    let mut total = 0.0;
153    for i in 0..coords.len() - 1 {
154        let a = Point::new(coords[i].x, coords[i].y);
155        let b = Point::new(coords[i + 1].x, coords[i + 1].y);
156        let d = a.haversine_distance(&b);
157        seg_lens.push(d);
158        total += d;
159    }
160    if total <= 0.0 {
161        return Point::new(coords[0].x, coords[0].y);
162    }
163
164    let target = t * total;
165    let mut acc = 0.0;
166    for (i, &seg_len) in seg_lens.iter().enumerate() {
167        if acc + seg_len >= target || i == seg_lens.len() - 1 {
168            let local_t = if seg_len > 0.0 {
169                ((target - acc) / seg_len).clamp(0.0, 1.0)
170            } else {
171                0.0
172            };
173            let p1 = &coords[i];
174            let p2 = &coords[i + 1];
175            return Point::new(
176                p1.x + local_t * (p2.x - p1.x),
177                p1.y + local_t * (p2.y - p1.y),
178            );
179        }
180        acc += seg_len;
181    }
182
183    Point::new(coords[0].x, coords[0].y)
184}