Skip to main content

tp_lib_core/detections/
resolve.rs

1//! Detection resolution (topological / coordinate → ResolvedAnchor) (T018, T030).
2//!
3//! Converts validated, time-filtered [`Detection`] values into
4//! [`ResolvedAnchor`] values plus per-detection [`DetectionRecord`] provenance
5//! entries.
6//!
7//! Topological detections (those carrying a `netelement_id`) resolve directly.
8//! Coordinate-only punctual detections are reprojected to the network CRS and
9//! matched to the nearest netelement via a linear scan
10//! (`crate::path::candidate::calculate_closest_point_on_linestring`). A
11//! detection whose nearest netelement is farther than `cutoff_distance_m`
12//! is discarded with [`DiscardReason::OutOfReach`].
13
14use chrono::{DateTime, FixedOffset};
15use geo::Point;
16
17use crate::crs::transform::CrsTransformer;
18use crate::models::{
19    Detection, DetectionKind, DetectionRecord, DetectionStatus, DiscardReason, GnssPosition,
20    LinearDetection, Netelement, PunctualDetection, ResolvedAnchor, TimestampOrRange,
21};
22use crate::path::candidate::calculate_closest_point_on_linestring;
23
24use super::error::DetectionError;
25
26/// Per-detection resolution outcome.
27#[derive(Debug, Clone)]
28pub struct ResolutionOutcome {
29    /// Successfully built anchors (sorted later by [`crate::detections::prepare_detections`]).
30    pub anchors: Vec<ResolvedAnchor>,
31    /// One [`DetectionRecord`] per input detection (applied / resolved /
32    /// discarded). Caller is responsible for merging this with
33    /// pre-existing duplicate / out-of-window records.
34    pub records: Vec<DetectionRecord>,
35    /// Free-form warnings (e.g. coordinate-only detection beyond cutoff).
36    pub warnings: Vec<String>,
37}
38
39/// Resolve every kept detection into an anchor (or a discard record).
40///
41/// `gnss` MUST be sorted by timestamp. `cutoff_distance_m` applies to
42/// coordinate-only resolution only.
43pub fn resolve_detections(
44    detections: Vec<Detection>,
45    gnss: &[GnssPosition],
46    netelements: &[Netelement],
47    cutoff_distance_m: f64,
48) -> Result<ResolutionOutcome, DetectionError> {
49    let mut out = ResolutionOutcome {
50        anchors: Vec::new(),
51        records: Vec::new(),
52        warnings: Vec::new(),
53    };
54
55    if gnss.is_empty() {
56        return Ok(out);
57    }
58
59    for det in detections.into_iter() {
60        match det {
61            Detection::Punctual(p) => {
62                resolve_punctual(p, gnss, netelements, cutoff_distance_m, &mut out)?
63            }
64            Detection::Linear(l) => resolve_linear(l, gnss, &mut out)?,
65        }
66    }
67
68    Ok(out)
69}
70
71fn resolve_punctual(
72    p: PunctualDetection,
73    gnss: &[GnssPosition],
74    netelements: &[Netelement],
75    cutoff_distance_m: f64,
76    out: &mut ResolutionOutcome,
77) -> Result<(), DetectionError> {
78    let gnss_index = nearest_gnss_index(gnss, p.timestamp);
79
80    if let Some(loc) = &p.location {
81        // Topological — direct anchor.
82        out.anchors.push(ResolvedAnchor::Punctual {
83            netelement_id: loc.netelement_id.clone(),
84            intrinsic: loc.intrinsic,
85            gnss_index,
86        });
87        out.records.push(DetectionRecord {
88            source_file: p.source_file.clone(),
89            source_row: p.source_row,
90            kind: DetectionKind::Punctual,
91            timestamp: TimestampOrRange::Single {
92                timestamp: p.timestamp,
93            },
94            status: DetectionStatus::Applied {
95                netelement_id: loc.netelement_id.clone(),
96                intrinsic: loc.intrinsic,
97            },
98            id: p.id.clone(),
99            source: p.source.clone(),
100            metadata: p.metadata.clone(),
101        });
102        return Ok(());
103    }
104
105    if let Some(coords) = &p.coordinates {
106        if coords.crs.trim().is_empty() {
107            return Err(DetectionError::MissingCrs {
108                source_file: p.source_file.clone(),
109                source_row: p.source_row,
110            });
111        }
112
113        // Reproject (lat, lon) from coords.crs to WGS84 (network CRS) so that
114        // distance in metres is comparable with cutoff.
115        let projected = reproject_to_wgs84(coords.longitude, coords.latitude, &coords.crs)
116            .map_err(|e| DetectionError::Parse {
117                source_file: p.source_file.clone(),
118                source_row: p.source_row,
119                message: format!("CRS reprojection failed ({}): {}", coords.crs, e),
120            })?;
121        let detection_point = Point::new(projected.0, projected.1);
122
123        // Linear scan for nearest netelement.
124        let mut best: Option<(usize, f64, f64)> = None; // (idx, distance_m, intrinsic)
125        for (idx, ne) in netelements.iter().enumerate() {
126            let (distance, intrinsic, _) =
127                calculate_closest_point_on_linestring(&detection_point, &ne.geometry).map_err(
128                    |e| DetectionError::Parse {
129                        source_file: p.source_file.clone(),
130                        source_row: p.source_row,
131                        message: format!("projection error: {}", e),
132                    },
133                )?;
134            match best {
135                None => best = Some((idx, distance, intrinsic)),
136                Some((_, d, _)) if distance < d => best = Some((idx, distance, intrinsic)),
137                _ => {}
138            }
139        }
140
141        let (best_idx, best_dist, best_intrinsic) = match best {
142            Some(b) => b,
143            None => {
144                // No netelements — treat as out-of-reach with finite sentinel.
145                out.warnings.push(format!(
146                    "detection at {}:{} discarded (no netelements available)",
147                    p.source_file, p.source_row
148                ));
149                out.records.push(DetectionRecord {
150                    source_file: p.source_file.clone(),
151                    source_row: p.source_row,
152                    kind: DetectionKind::Punctual,
153                    timestamp: TimestampOrRange::Single {
154                        timestamp: p.timestamp,
155                    },
156                    status: DetectionStatus::Discarded {
157                        reason: DiscardReason::OutOfReach {
158                            nearest_distance_m: cutoff_distance_m + 1.0,
159                            cutoff_m: cutoff_distance_m,
160                        },
161                    },
162                    id: p.id.clone(),
163                    source: p.source.clone(),
164                    metadata: p.metadata.clone(),
165                });
166                return Ok(());
167            }
168        };
169
170        if best_dist <= cutoff_distance_m {
171            let netelement_id = netelements[best_idx].id.clone();
172            out.anchors.push(ResolvedAnchor::Punctual {
173                netelement_id: netelement_id.clone(),
174                intrinsic: best_intrinsic,
175                gnss_index,
176            });
177            out.records.push(DetectionRecord {
178                source_file: p.source_file.clone(),
179                source_row: p.source_row,
180                kind: DetectionKind::Punctual,
181                timestamp: TimestampOrRange::Single {
182                    timestamp: p.timestamp,
183                },
184                status: DetectionStatus::Resolved {
185                    netelement_id,
186                    distance_m: best_dist,
187                },
188                id: p.id.clone(),
189                source: p.source.clone(),
190                metadata: p.metadata.clone(),
191            });
192        } else {
193            out.warnings.push(format!(
194                "detection at {}:{} discarded (nearest netelement {:.2} m > cutoff {:.2} m)",
195                p.source_file, p.source_row, best_dist, cutoff_distance_m
196            ));
197            out.records.push(DetectionRecord {
198                source_file: p.source_file.clone(),
199                source_row: p.source_row,
200                kind: DetectionKind::Punctual,
201                timestamp: TimestampOrRange::Single {
202                    timestamp: p.timestamp,
203                },
204                status: DetectionStatus::Discarded {
205                    reason: DiscardReason::OutOfReach {
206                        nearest_distance_m: best_dist,
207                        cutoff_m: cutoff_distance_m,
208                    },
209                },
210                id: p.id.clone(),
211                source: p.source.clone(),
212                metadata: p.metadata.clone(),
213            });
214        }
215        return Ok(());
216    }
217
218    // Neither location nor coordinates — should have been caught at load.
219    Err(DetectionError::InvalidSchema(format!(
220        "punctual detection at {}:{} missing both `location` and `coordinates`",
221        p.source_file, p.source_row
222    )))
223}
224
225fn resolve_linear(
226    l: LinearDetection,
227    gnss: &[GnssPosition],
228    out: &mut ResolutionOutcome,
229) -> Result<(), DetectionError> {
230    // gnss_range = all indices i where gnss[i].timestamp ∈ [t_from, t_to]
231    let mut first: Option<usize> = None;
232    let mut last: Option<usize> = None;
233    for (i, g) in gnss.iter().enumerate() {
234        if g.timestamp >= l.t_from && g.timestamp <= l.t_to {
235            if first.is_none() {
236                first = Some(i);
237            }
238            last = Some(i);
239        }
240    }
241
242    match (first, last) {
243        (Some(lo), Some(hi)) => {
244            out.anchors.push(ResolvedAnchor::Linear {
245                netelement_id: l.netelement_id.clone(),
246                start_intrinsic: l.start_intrinsic,
247                end_intrinsic: l.end_intrinsic,
248                gnss_range: lo..=hi,
249            });
250            out.records.push(DetectionRecord {
251                source_file: l.source_file.clone(),
252                source_row: l.source_row,
253                kind: DetectionKind::Linear,
254                timestamp: TimestampOrRange::Range {
255                    t_from: l.t_from,
256                    t_to: l.t_to,
257                },
258                status: DetectionStatus::Applied {
259                    netelement_id: l.netelement_id.clone(),
260                    // For linear records, surface the start_intrinsic as a
261                    // representative value (end_intrinsic kept on the anchor).
262                    intrinsic: l.start_intrinsic,
263                },
264                id: l.id.clone(),
265                source: l.source.clone(),
266                metadata: l.metadata.clone(),
267            });
268        }
269        _ => {
270            // No GNSS index falls inside the window — discard.
271            let gnss_first = gnss.first().unwrap().timestamp;
272            let gnss_last = gnss.last().unwrap().timestamp;
273            out.records.push(DetectionRecord {
274                source_file: l.source_file.clone(),
275                source_row: l.source_row,
276                kind: DetectionKind::Linear,
277                timestamp: TimestampOrRange::Range {
278                    t_from: l.t_from,
279                    t_to: l.t_to,
280                },
281                status: DetectionStatus::Discarded {
282                    reason: DiscardReason::OutOfTimeRange {
283                        gnss_first,
284                        gnss_last,
285                    },
286                },
287                id: l.id.clone(),
288                source: l.source.clone(),
289                metadata: l.metadata.clone(),
290            });
291            out.warnings.push(format!(
292                "linear detection at {}:{} window has no GNSS samples — discarded",
293                l.source_file, l.source_row,
294            ));
295        }
296    }
297
298    Ok(())
299}
300
301/// Pick the GNSS index with timestamp closest to `t` (ties → earlier index).
302fn nearest_gnss_index(gnss: &[GnssPosition], t: DateTime<FixedOffset>) -> usize {
303    debug_assert!(!gnss.is_empty());
304    let mut best_idx = 0usize;
305    let mut best_diff = (gnss[0].timestamp - t).num_milliseconds().abs();
306    for (i, g) in gnss.iter().enumerate().skip(1) {
307        let diff = (g.timestamp - t).num_milliseconds().abs();
308        if diff < best_diff {
309            best_diff = diff;
310            best_idx = i;
311        }
312    }
313    best_idx
314}
315
316/// Reproject `(lon, lat)` from `crs` (EPSG code) into WGS84 lon/lat (degrees).
317///
318/// Returns `(lon, lat)`.
319fn reproject_to_wgs84(lon: f64, lat: f64, crs: &str) -> Result<(f64, f64), String> {
320    if crs.eq_ignore_ascii_case("EPSG:4326") {
321        return Ok((lon, lat));
322    }
323    let xform = CrsTransformer::new(crs.to_string(), "EPSG:4326".to_string())
324        .map_err(|e| format!("{}", e))?;
325    let pt = xform
326        .transform(Point::new(lon, lat))
327        .map_err(|e| format!("{}", e))?;
328    Ok((pt.x(), pt.y()))
329}