use crate::errors::ProjectionError;
use crate::models::{GnssPosition, Netelement};
use geo::{LineString, Point};
const EDGE_INTRINSIC_THRESHOLD: f64 = 1e-6;
#[derive(Debug, Clone)]
pub struct CandidateNetElement {
pub netelement_id: String,
pub distance_meters: f64,
pub intrinsic_coordinate: f64,
pub projected_point: Point<f64>,
}
pub fn find_candidate_netelements(
gnss_pos: &GnssPosition,
netelements: &[Netelement],
cutoff_distance: f64,
max_candidates: usize,
) -> Result<Vec<CandidateNetElement>, ProjectionError> {
let gnss_point = Point::new(gnss_pos.longitude, gnss_pos.latitude);
let mut candidates = Vec::new();
for netelement in netelements {
let (distance, intrinsic, proj_point) =
calculate_closest_point_on_linestring(&gnss_point, &netelement.geometry)?;
if distance <= cutoff_distance {
candidates.push(CandidateNetElement {
netelement_id: netelement.id.clone(),
distance_meters: distance,
intrinsic_coordinate: intrinsic,
projected_point: proj_point,
});
}
}
let has_interior = candidates.iter().any(|c| {
c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
&& c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
});
if has_interior {
candidates.retain(|c| {
c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
&& c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
});
}
candidates.sort_by(|a, b| a.distance_meters.partial_cmp(&b.distance_meters).unwrap());
candidates.truncate(max_candidates);
Ok(candidates)
}
pub(crate) fn calculate_closest_point_on_linestring(
point: &Point<f64>,
linestring: &LineString<f64>,
) -> Result<(f64, f64, Point<f64>), ProjectionError> {
use geo::algorithm::haversine_distance::HaversineDistance;
let coords = &linestring.0;
let cos_lat = point.y().to_radians().cos();
let mut min_dist_sq = f64::INFINITY;
let mut best_seg: usize = 0;
let mut best_t: f64 = 0.0;
let mut closest_point = coords[0];
for i in 0..coords.len() - 1 {
let p1 = &coords[i];
let p2 = &coords[i + 1];
let dx = (p2.x - p1.x) * cos_lat;
let dy = p2.y - p1.y;
let len_sq = dx * dx + dy * dy;
let t = if len_sq > 0.0 {
let px = (point.x() - p1.x) * cos_lat;
let py = point.y() - p1.y;
((px * dx + py * dy) / len_sq).clamp(0.0, 1.0)
} else {
0.0
};
let proj_x = p1.x + t * (p2.x - p1.x);
let proj_y = p1.y + t * (p2.y - p1.y);
let ex = (point.x() - proj_x) * cos_lat;
let ey = point.y() - proj_y;
let dist_sq = ex * ex + ey * ey;
if dist_sq < min_dist_sq {
min_dist_sq = dist_sq;
closest_point = geo::Coord {
x: proj_x,
y: proj_y,
};
best_seg = i;
best_t = t;
}
}
let closest_pt = Point::new(closest_point.x, closest_point.y);
let distance = point.haversine_distance(&closest_pt);
let mut length_before = 0.0;
for i in 0..best_seg {
let a = Point::new(coords[i].x, coords[i].y);
let b = Point::new(coords[i + 1].x, coords[i + 1].y);
length_before += a.haversine_distance(&b);
}
let seg_start = Point::new(coords[best_seg].x, coords[best_seg].y);
let seg_end = Point::new(coords[best_seg + 1].x, coords[best_seg + 1].y);
let seg_length = seg_start.haversine_distance(&seg_end);
length_before += best_t * seg_length;
let total_length: f64 = (0..coords.len() - 1)
.map(|i| {
let a = Point::new(coords[i].x, coords[i].y);
let b = Point::new(coords[i + 1].x, coords[i + 1].y);
a.haversine_distance(&b)
})
.sum();
let intrinsic = if total_length > 0.0 {
(length_before / total_length).clamp(0.0, 1.0)
} else {
0.0
};
Ok((distance, intrinsic, closest_pt))
}
pub fn calculate_heading_at_point(
point: &Point<f64>,
linestring: &LineString<f64>,
) -> Result<f64, ProjectionError> {
let coords: Vec<_> = linestring.points().collect();
if coords.len() < 2 {
return Ok(0.0);
}
let cos_lat = point.y().to_radians().cos();
let px = point.x() * 111_320.0 * cos_lat;
let py = point.y() * 111_320.0;
let mut closest_segment_idx = 0;
let mut closest_distance = f64::MAX;
for i in 0..coords.len() - 1 {
let ax = coords[i].x() * 111_320.0 * cos_lat;
let ay = coords[i].y() * 111_320.0;
let bx = coords[i + 1].x() * 111_320.0 * cos_lat;
let by = coords[i + 1].y() * 111_320.0;
let dx = bx - ax;
let dy = by - ay;
let seg_len_sq = dx * dx + dy * dy;
let t = if seg_len_sq < 1e-18 {
0.0
} else {
((px - ax) * dx + (py - ay) * dy) / seg_len_sq
}
.clamp(0.0, 1.0);
let proj_x = ax + t * dx;
let proj_y = ay + t * dy;
let dist = ((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt();
if dist < closest_distance {
closest_distance = dist;
closest_segment_idx = i;
}
}
let p1 = coords[closest_segment_idx];
let p2 = coords[closest_segment_idx + 1];
Ok(haversine_bearing(
&Point::new(p1.x(), p1.y()),
&Point::new(p2.x(), p2.y()),
))
}
pub fn estimate_headings_from_neighbors(positions: &[&GnssPosition]) -> Vec<Option<f64>> {
let n = positions.len();
let mut headings: Vec<Option<f64>> = vec![None; n];
if n < 3 {
return headings;
}
for x in 1..n - 1 {
let prev = Point::new(positions[x - 1].longitude, positions[x - 1].latitude);
let curr = Point::new(positions[x].longitude, positions[x].latitude);
let next = Point::new(positions[x + 1].longitude, positions[x + 1].latitude);
let d_prev = haversine_distance(&prev, &curr);
let d_next = haversine_distance(&curr, &next);
let max_d = d_prev.max(d_next);
if max_d < 1e-9 {
continue; }
if (d_prev - d_next).abs() / max_d >= 0.20 {
continue;
}
let h_back = haversine_bearing(&prev, &curr);
let h_fwd = haversine_bearing(&curr, &next);
if directional_heading_difference(h_back, h_fwd) >= 5.0 {
continue;
}
headings[x] = Some(haversine_bearing(&prev, &next));
}
let raw_headings = headings.clone();
for x in 2..n - 1 {
if let Some(h) = headings[x] {
if let Some(prev_h) = raw_headings[x - 1] {
if heading_difference(h, prev_h) >= 5.0 {
headings[x] = None;
}
}
}
}
headings
}
fn haversine_distance(a: &Point<f64>, b: &Point<f64>) -> f64 {
let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
let dlat = lat2 - lat1;
let dlon = lon2 - lon1;
let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
2.0 * 6_371_000.0 * h.sqrt().asin()
}
pub(crate) fn haversine_bearing(a: &Point<f64>, b: &Point<f64>) -> f64 {
let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
let dlon = lon2 - lon1;
let x = dlon.sin() * lat2.cos();
let y = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlon.cos();
(x.atan2(y).to_degrees() + 360.0) % 360.0
}
pub fn heading_difference(heading1: f64, heading2: f64) -> f64 {
let diff = (heading1 - heading2).abs() % 360.0;
let diff = if diff > 180.0 { 360.0 - diff } else { diff };
if diff > 90.0 {
180.0 - diff
} else {
diff
}
}
pub(crate) fn directional_heading_difference(heading1: f64, heading2: f64) -> f64 {
let diff = (heading1 - heading2).abs() % 360.0;
if diff > 180.0 {
360.0 - diff
} else {
diff
}
}
#[cfg(test)]
mod tests {
use super::*;
use geo::LineString;
#[test]
fn test_heading_difference_same_direction() {
let diff = heading_difference(45.0, 45.0);
assert_eq!(diff, 0.0);
}
#[test]
fn test_heading_difference_opposite_direction() {
let diff = heading_difference(0.0, 180.0);
assert_eq!(diff, 0.0);
}
#[test]
fn test_heading_difference_wraparound() {
let diff = heading_difference(350.0, 10.0);
assert_eq!(diff, 20.0);
}
#[test]
fn test_heading_difference_perpendicular() {
let diff = heading_difference(0.0, 90.0);
assert_eq!(diff, 90.0);
}
#[test]
fn test_heading_difference_near_opposite() {
let diff = heading_difference(0.0, 170.0);
assert_eq!(diff, 10.0);
}
#[test]
fn test_candidate_selection_within_cutoff() {
let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
let netelement =
Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
let gnss = GnssPosition::new(
50.8502,
4.3502,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &[netelement], 50.0, 10).unwrap();
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].netelement_id, "NE_TEST");
assert!(candidates[0].distance_meters < 50.0);
}
#[test]
fn test_candidate_selection_beyond_cutoff() {
let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
let netelement =
Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
let gnss = GnssPosition::new(
50.90,
4.40,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &[netelement], 1.0, 10).unwrap();
assert_eq!(candidates.len(), 0);
}
#[test]
fn test_candidate_max_candidates_limit() {
let netelements = vec![
Netelement::new(
"NE_1".to_string(),
LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_2".to_string(),
LineString::from(vec![(4.3502, 50.8502), (4.3512, 50.8512)]),
"EPSG:4326".to_string(),
)
.unwrap(),
Netelement::new(
"NE_3".to_string(),
LineString::from(vec![(4.3503, 50.8503), (4.3513, 50.8513)]),
"EPSG:4326".to_string(),
)
.unwrap(),
];
let gnss = GnssPosition::new(
50.8502,
4.3502,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &netelements, 500.0, 2).unwrap();
assert!(candidates.len() <= 2);
}
#[test]
fn test_edge_projection_at_start_kept_as_fallback() {
let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
let netelement =
Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
let gnss = GnssPosition::new(
50.850,
4.350,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
assert_eq!(
candidates.len(),
1,
"Edge candidate kept as fallback when no interior candidates exist"
);
}
#[test]
fn test_edge_projection_at_end_kept_as_fallback() {
let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
let netelement =
Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
let gnss = GnssPosition::new(
50.850,
4.360,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
assert_eq!(
candidates.len(),
1,
"Edge candidate kept as fallback when no interior candidates exist"
);
}
#[test]
fn test_mid_range_projection_accepted() {
let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
let netelement =
Netelement::new("NE_MID".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
let gnss = GnssPosition::new(
50.850,
4.355,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
assert_eq!(candidates.len(), 1);
assert!(
candidates[0].intrinsic_coordinate > 0.1 && candidates[0].intrinsic_coordinate < 0.9,
"Midpoint candidate should have intrinsic near 0.5"
);
}
#[test]
fn test_edge_candidate_removed_when_interior_exists() {
let ne_interior = Netelement::new(
"NE_INT".to_string(),
LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]),
"EPSG:4326".to_string(),
)
.unwrap();
let ne_edge = Netelement::new(
"NE_EDGE".to_string(),
LineString::from(vec![(4.353, 50.851), (4.355, 50.851)]),
"EPSG:4326".to_string(),
)
.unwrap();
let gnss = GnssPosition::new(
50.850,
4.355,
chrono::Utc::now().into(),
"EPSG:4326".to_string(),
)
.unwrap();
let candidates =
find_candidate_netelements(&gnss, &[ne_interior, ne_edge], 500.0, 10).unwrap();
assert!(
candidates.iter().all(|c| c.netelement_id != "NE_EDGE"
|| (c.intrinsic_coordinate > 1e-6 && c.intrinsic_coordinate < 1.0 - 1e-6)),
"Edge candidates should be removed when interior candidates exist"
);
}
fn make_gnss(lat: f64, lon: f64) -> GnssPosition {
GnssPosition::new(lat, lon, chrono::Utc::now().into(), "EPSG:4326".to_string()).unwrap()
}
#[test]
fn test_estimate_headings_straight_north() {
let positions = [
make_gnss(50.000, 4.000),
make_gnss(50.001, 4.000),
make_gnss(50.002, 4.000),
];
let refs: Vec<&GnssPosition> = positions.iter().collect();
let headings = estimate_headings_from_neighbors(&refs);
assert!(headings[0].is_none(), "First position should be None");
assert!(headings[2].is_none(), "Last position should be None");
let h = headings[1].expect("Middle position should have estimated heading");
assert!(!(1.0..=359.0).contains(&h), "Expected ~0° north, got {h}");
}
#[test]
fn test_estimate_headings_straight_east() {
let positions = [
make_gnss(50.000, 4.000),
make_gnss(50.000, 4.001),
make_gnss(50.000, 4.002),
];
let refs: Vec<&GnssPosition> = positions.iter().collect();
let headings = estimate_headings_from_neighbors(&refs);
let h = headings[1].expect("Middle position should have estimated heading");
assert!((h - 90.0).abs() < 1.0, "Expected ~90° east, got {h}");
}
#[test]
fn test_estimate_headings_endpoints_none() {
let positions = [make_gnss(50.000, 4.000), make_gnss(50.001, 4.000)];
let refs: Vec<&GnssPosition> = positions.iter().collect();
let headings = estimate_headings_from_neighbors(&refs);
assert!(
headings.iter().all(|h| h.is_none()),
"With < 3 positions all should be None"
);
}
#[test]
fn test_estimate_headings_unequal_spacing() {
let positions = [
make_gnss(50.000, 4.000),
make_gnss(50.010, 4.000), make_gnss(50.0101, 4.000), ];
let refs: Vec<&GnssPosition> = positions.iter().collect();
let headings = estimate_headings_from_neighbors(&refs);
assert!(
headings[1].is_none(),
"Unequal spacing should reject estimated heading"
);
}
#[test]
fn test_estimate_headings_continuity_rejection() {
let positions = [
make_gnss(50.000, 4.000),
make_gnss(50.001, 4.000), make_gnss(50.002, 4.000), make_gnss(50.002, 4.001), make_gnss(50.002, 4.002),
];
let refs: Vec<&GnssPosition> = positions.iter().collect();
let headings = estimate_headings_from_neighbors(&refs);
assert!(headings[1].is_some(), "Position 1 heading should be valid");
let has_rejection = headings[2].is_none() || headings[3].is_none();
assert!(
has_rejection,
"Sharp turn should cause at least one heading rejection"
);
}
}