use chrono::{DateTime, FixedOffset};
use geo::Point;
use crate::crs::transform::CrsTransformer;
use crate::models::{
Detection, DetectionKind, DetectionRecord, DetectionStatus, DiscardReason, GnssPosition,
LinearDetection, Netelement, PunctualDetection, ResolvedAnchor, TimestampOrRange,
};
use crate::path::candidate::calculate_closest_point_on_linestring;
use super::error::DetectionError;
#[derive(Debug, Clone)]
pub struct ResolutionOutcome {
pub anchors: Vec<ResolvedAnchor>,
pub records: Vec<DetectionRecord>,
pub warnings: Vec<String>,
}
pub fn resolve_detections(
detections: Vec<Detection>,
gnss: &[GnssPosition],
netelements: &[Netelement],
cutoff_distance_m: f64,
) -> Result<ResolutionOutcome, DetectionError> {
let mut out = ResolutionOutcome {
anchors: Vec::new(),
records: Vec::new(),
warnings: Vec::new(),
};
if gnss.is_empty() {
return Ok(out);
}
for det in detections.into_iter() {
match det {
Detection::Punctual(p) => {
resolve_punctual(p, gnss, netelements, cutoff_distance_m, &mut out)?
}
Detection::Linear(l) => resolve_linear(l, gnss, &mut out)?,
}
}
Ok(out)
}
fn resolve_punctual(
p: PunctualDetection,
gnss: &[GnssPosition],
netelements: &[Netelement],
cutoff_distance_m: f64,
out: &mut ResolutionOutcome,
) -> Result<(), DetectionError> {
let gnss_index = nearest_gnss_index(gnss, p.timestamp);
if let Some(loc) = &p.location {
out.anchors.push(ResolvedAnchor::Punctual {
netelement_id: loc.netelement_id.clone(),
intrinsic: loc.intrinsic,
gnss_index,
});
out.records.push(DetectionRecord {
source_file: p.source_file.clone(),
source_row: p.source_row,
kind: DetectionKind::Punctual,
timestamp: TimestampOrRange::Single {
timestamp: p.timestamp,
},
status: DetectionStatus::Applied {
netelement_id: loc.netelement_id.clone(),
intrinsic: loc.intrinsic,
},
id: p.id.clone(),
source: p.source.clone(),
metadata: p.metadata.clone(),
});
return Ok(());
}
if let Some(coords) = &p.coordinates {
if coords.crs.trim().is_empty() {
return Err(DetectionError::MissingCrs {
source_file: p.source_file.clone(),
source_row: p.source_row,
});
}
let projected = reproject_to_wgs84(coords.longitude, coords.latitude, &coords.crs)
.map_err(|e| DetectionError::Parse {
source_file: p.source_file.clone(),
source_row: p.source_row,
message: format!("CRS reprojection failed ({}): {}", coords.crs, e),
})?;
let detection_point = Point::new(projected.0, projected.1);
let mut best: Option<(usize, f64, f64)> = None; for (idx, ne) in netelements.iter().enumerate() {
let (distance, intrinsic, _) =
calculate_closest_point_on_linestring(&detection_point, &ne.geometry).map_err(
|e| DetectionError::Parse {
source_file: p.source_file.clone(),
source_row: p.source_row,
message: format!("projection error: {}", e),
},
)?;
match best {
None => best = Some((idx, distance, intrinsic)),
Some((_, d, _)) if distance < d => best = Some((idx, distance, intrinsic)),
_ => {}
}
}
let (best_idx, best_dist, best_intrinsic) = match best {
Some(b) => b,
None => {
out.warnings.push(format!(
"detection at {}:{} discarded (no netelements available)",
p.source_file, p.source_row
));
out.records.push(DetectionRecord {
source_file: p.source_file.clone(),
source_row: p.source_row,
kind: DetectionKind::Punctual,
timestamp: TimestampOrRange::Single {
timestamp: p.timestamp,
},
status: DetectionStatus::Discarded {
reason: DiscardReason::OutOfReach {
nearest_distance_m: cutoff_distance_m + 1.0,
cutoff_m: cutoff_distance_m,
},
},
id: p.id.clone(),
source: p.source.clone(),
metadata: p.metadata.clone(),
});
return Ok(());
}
};
if best_dist <= cutoff_distance_m {
let netelement_id = netelements[best_idx].id.clone();
out.anchors.push(ResolvedAnchor::Punctual {
netelement_id: netelement_id.clone(),
intrinsic: best_intrinsic,
gnss_index,
});
out.records.push(DetectionRecord {
source_file: p.source_file.clone(),
source_row: p.source_row,
kind: DetectionKind::Punctual,
timestamp: TimestampOrRange::Single {
timestamp: p.timestamp,
},
status: DetectionStatus::Resolved {
netelement_id,
distance_m: best_dist,
},
id: p.id.clone(),
source: p.source.clone(),
metadata: p.metadata.clone(),
});
} else {
out.warnings.push(format!(
"detection at {}:{} discarded (nearest netelement {:.2} m > cutoff {:.2} m)",
p.source_file, p.source_row, best_dist, cutoff_distance_m
));
out.records.push(DetectionRecord {
source_file: p.source_file.clone(),
source_row: p.source_row,
kind: DetectionKind::Punctual,
timestamp: TimestampOrRange::Single {
timestamp: p.timestamp,
},
status: DetectionStatus::Discarded {
reason: DiscardReason::OutOfReach {
nearest_distance_m: best_dist,
cutoff_m: cutoff_distance_m,
},
},
id: p.id.clone(),
source: p.source.clone(),
metadata: p.metadata.clone(),
});
}
return Ok(());
}
Err(DetectionError::InvalidSchema(format!(
"punctual detection at {}:{} missing both `location` and `coordinates`",
p.source_file, p.source_row
)))
}
fn resolve_linear(
l: LinearDetection,
gnss: &[GnssPosition],
out: &mut ResolutionOutcome,
) -> Result<(), DetectionError> {
let mut first: Option<usize> = None;
let mut last: Option<usize> = None;
for (i, g) in gnss.iter().enumerate() {
if g.timestamp >= l.t_from && g.timestamp <= l.t_to {
if first.is_none() {
first = Some(i);
}
last = Some(i);
}
}
match (first, last) {
(Some(lo), Some(hi)) => {
out.anchors.push(ResolvedAnchor::Linear {
netelement_id: l.netelement_id.clone(),
start_intrinsic: l.start_intrinsic,
end_intrinsic: l.end_intrinsic,
gnss_range: lo..=hi,
});
out.records.push(DetectionRecord {
source_file: l.source_file.clone(),
source_row: l.source_row,
kind: DetectionKind::Linear,
timestamp: TimestampOrRange::Range {
t_from: l.t_from,
t_to: l.t_to,
},
status: DetectionStatus::Applied {
netelement_id: l.netelement_id.clone(),
intrinsic: l.start_intrinsic,
},
id: l.id.clone(),
source: l.source.clone(),
metadata: l.metadata.clone(),
});
}
_ => {
let gnss_first = gnss.first().unwrap().timestamp;
let gnss_last = gnss.last().unwrap().timestamp;
out.records.push(DetectionRecord {
source_file: l.source_file.clone(),
source_row: l.source_row,
kind: DetectionKind::Linear,
timestamp: TimestampOrRange::Range {
t_from: l.t_from,
t_to: l.t_to,
},
status: DetectionStatus::Discarded {
reason: DiscardReason::OutOfTimeRange {
gnss_first,
gnss_last,
},
},
id: l.id.clone(),
source: l.source.clone(),
metadata: l.metadata.clone(),
});
out.warnings.push(format!(
"linear detection at {}:{} window has no GNSS samples — discarded",
l.source_file, l.source_row,
));
}
}
Ok(())
}
fn nearest_gnss_index(gnss: &[GnssPosition], t: DateTime<FixedOffset>) -> usize {
debug_assert!(!gnss.is_empty());
let mut best_idx = 0usize;
let mut best_diff = (gnss[0].timestamp - t).num_milliseconds().abs();
for (i, g) in gnss.iter().enumerate().skip(1) {
let diff = (g.timestamp - t).num_milliseconds().abs();
if diff < best_diff {
best_diff = diff;
best_idx = i;
}
}
best_idx
}
fn reproject_to_wgs84(lon: f64, lat: f64, crs: &str) -> Result<(f64, f64), String> {
if crs.eq_ignore_ascii_case("EPSG:4326") {
return Ok((lon, lat));
}
let xform = CrsTransformer::new(crs.to_string(), "EPSG:4326".to_string())
.map_err(|e| format!("{}", e))?;
let pt = xform
.transform(Point::new(lon, lat))
.map_err(|e| format!("{}", e))?;
Ok((pt.x(), pt.y()))
}