Skip to main content

tp_lib_core/
detections.rs

1//! Train detections — punctual & linear absolute position anchors.
2//!
3//! See `specs/004-train-detections/` for the design.
4
5pub mod anchor;
6pub mod error;
7pub mod filter;
8pub mod load;
9pub mod resolve;
10pub mod validate;
11
12pub use error::DetectionError;
13
14use std::collections::HashMap;
15use std::path::Path;
16
17use crate::models::{
18    Detection, DetectionKind, DetectionRecord, GnssPosition, Netelement, ResolvedAnchor,
19};
20
21/// Output of [`prepare_detections`].
22#[derive(Debug, Clone, Default)]
23pub struct PreparedDetections {
24    /// Anchors ready to inject into the Viterbi pipeline (sorted by first
25    /// affected GNSS index).
26    pub anchors: Vec<ResolvedAnchor>,
27    /// Per-detection provenance (applied / resolved / discarded), preserving
28    /// original input order.
29    pub records: Vec<DetectionRecord>,
30    /// Free-form warnings collected during filter / resolve.
31    pub warnings: Vec<String>,
32}
33
34/// Load → validate → time-filter → resolve a single detections file.
35///
36/// `expected_kind` reflects the originating CLI flag.
37pub fn prepare_detections(
38    path: &Path,
39    expected_kind: DetectionKind,
40    gnss: &[GnssPosition],
41    netelements: &[Netelement],
42    cutoff_distance_m: f64,
43) -> Result<PreparedDetections, DetectionError> {
44    let detections = load::load_detections(path, expected_kind)?;
45    prepare_detections_from_loaded(detections, gnss, netelements, cutoff_distance_m)
46}
47
48/// Same as [`prepare_detections`] but skips the load step (useful for tests
49/// and for combining detections from multiple files).
50pub fn prepare_detections_from_loaded(
51    detections: Vec<Detection>,
52    gnss: &[GnssPosition],
53    netelements: &[Netelement],
54    cutoff_distance_m: f64,
55) -> Result<PreparedDetections, DetectionError> {
56    let mut warnings = Vec::new();
57    let mut all_records: Vec<DetectionRecord> = Vec::new();
58    let input_order: Vec<(String, usize)> = detections
59        .iter()
60        .map(|d| (d.source_file().to_owned(), d.source_row()))
61        .collect();
62
63    // 1. Validation (FATAL on conflicting / unknown / out-of-range).
64    let validated = validate::validate_detections(detections, netelements)?;
65    let kept_keys: Vec<(String, usize)> = validated
66        .kept
67        .iter()
68        .map(|d| (d.source_file().to_owned(), d.source_row()))
69        .collect();
70    all_records.extend(validated.duplicate_records);
71
72    // 2. Time-range filter.
73    let filtered = filter::filter_detections_by_time(validated.kept, gnss);
74    all_records.extend(filtered.discard_records);
75    warnings.extend(filtered.warnings);
76
77    // 3. Resolution (topological + coordinate-only).
78    let resolution =
79        resolve::resolve_detections(filtered.kept, gnss, netelements, cutoff_distance_m)?;
80    all_records.extend(resolution.records);
81    warnings.extend(resolution.warnings);
82
83    // Sort anchors by first affected GNSS index.
84    let mut anchors = resolution.anchors;
85    anchors.sort_by_key(|a| a.first_index());
86
87    // Rebuild records in original input order so duplicate `kept_index`
88    // references remain meaningful for `detection_provenance`.
89    let mut by_key: HashMap<(String, usize), DetectionRecord> = HashMap::new();
90    for rec in all_records {
91        by_key.insert((rec.source_file.clone(), rec.source_row), rec);
92    }
93    let mut records: Vec<DetectionRecord> = Vec::with_capacity(input_order.len());
94    for key in input_order {
95        if let Some(rec) = by_key.remove(&key) {
96            records.push(rec);
97        }
98    }
99    let mut leftovers: Vec<DetectionRecord> = by_key.into_values().collect();
100    leftovers.sort_by(|a, b| {
101        a.source_file
102            .cmp(&b.source_file)
103            .then(a.source_row.cmp(&b.source_row))
104    });
105    records.extend(leftovers);
106
107    let kept_lookup: HashMap<(String, usize), usize> = kept_keys
108        .into_iter()
109        .enumerate()
110        .map(|(idx, key)| (key, idx))
111        .collect();
112    let mut kept_index_to_provenance_index: Vec<Option<usize>> = vec![None; kept_lookup.len()];
113    for (provenance_idx, rec) in records.iter().enumerate() {
114        if let Some(&kept_idx) = kept_lookup.get(&(rec.source_file.clone(), rec.source_row)) {
115            kept_index_to_provenance_index[kept_idx] = Some(provenance_idx);
116        }
117    }
118    for rec in &mut records {
119        if let crate::models::DetectionStatus::Discarded {
120            reason: crate::models::DiscardReason::DuplicateOfPriorDetection { kept_index },
121        } = &mut rec.status
122        {
123            if let Some(Some(mapped)) = kept_index_to_provenance_index.get(*kept_index) {
124                *kept_index = *mapped;
125            }
126        }
127    }
128
129    Ok(PreparedDetections {
130        anchors,
131        records,
132        warnings,
133    })
134}