1use 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#[derive(Debug, Clone)]
28pub struct ResolutionOutcome {
29 pub anchors: Vec<ResolvedAnchor>,
31 pub records: Vec<DetectionRecord>,
35 pub warnings: Vec<String>,
37}
38
39pub 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 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 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 let mut best: Option<(usize, f64, f64)> = None; 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 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 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 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 intrinsic: l.start_intrinsic,
263 },
264 id: l.id.clone(),
265 source: l.source.clone(),
266 metadata: l.metadata.clone(),
267 });
268 }
269 _ => {
270 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
301fn 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
316fn 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}