1use crate::errors::ProjectionError;
7use crate::models::{GnssPosition, Netelement};
8use geo::{LineString, Point};
9
10const EDGE_INTRINSIC_THRESHOLD: f64 = 1e-6;
14
15#[derive(Debug, Clone)]
17pub struct CandidateNetElement {
18 pub netelement_id: String,
20
21 pub distance_meters: f64,
23
24 pub intrinsic_coordinate: f64,
27
28 pub projected_point: Point<f64>,
30}
31
32pub fn find_candidate_netelements(
47 gnss_pos: &GnssPosition,
48 netelements: &[Netelement],
49 cutoff_distance: f64,
50 max_candidates: usize,
51) -> Result<Vec<CandidateNetElement>, ProjectionError> {
52 let gnss_point = Point::new(gnss_pos.longitude, gnss_pos.latitude);
53 let mut candidates = Vec::new();
54
55 for netelement in netelements {
57 let (distance, intrinsic, proj_point) =
59 calculate_closest_point_on_linestring(&gnss_point, &netelement.geometry)?;
60
61 if distance <= cutoff_distance {
63 candidates.push(CandidateNetElement {
64 netelement_id: netelement.id.clone(),
65 distance_meters: distance,
66 intrinsic_coordinate: intrinsic,
67 projected_point: proj_point,
68 });
69 }
70 }
71
72 let has_interior = candidates.iter().any(|c| {
81 c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
82 && c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
83 });
84 if has_interior {
85 candidates.retain(|c| {
86 c.intrinsic_coordinate >= EDGE_INTRINSIC_THRESHOLD
87 && c.intrinsic_coordinate <= 1.0 - EDGE_INTRINSIC_THRESHOLD
88 });
89 }
90
91 candidates.sort_by(|a, b| a.distance_meters.partial_cmp(&b.distance_meters).unwrap());
93
94 candidates.truncate(max_candidates);
96
97 Ok(candidates)
98}
99
100fn calculate_closest_point_on_linestring(
116 point: &Point<f64>,
117 linestring: &LineString<f64>,
118) -> Result<(f64, f64, Point<f64>), ProjectionError> {
119 use geo::algorithm::haversine_distance::HaversineDistance;
120
121 let coords = &linestring.0;
122
123 let cos_lat = point.y().to_radians().cos();
127
128 let mut min_dist_sq = f64::INFINITY;
129 let mut best_seg: usize = 0;
130 let mut best_t: f64 = 0.0;
131 let mut closest_point = coords[0];
132
133 for i in 0..coords.len() - 1 {
134 let p1 = &coords[i];
135 let p2 = &coords[i + 1];
136
137 let dx = (p2.x - p1.x) * cos_lat;
138 let dy = p2.y - p1.y;
139 let len_sq = dx * dx + dy * dy;
140
141 let t = if len_sq > 0.0 {
142 let px = (point.x() - p1.x) * cos_lat;
143 let py = point.y() - p1.y;
144 ((px * dx + py * dy) / len_sq).clamp(0.0, 1.0)
145 } else {
146 0.0
147 };
148
149 let proj_x = p1.x + t * (p2.x - p1.x);
151 let proj_y = p1.y + t * (p2.y - p1.y);
152
153 let ex = (point.x() - proj_x) * cos_lat;
154 let ey = point.y() - proj_y;
155 let dist_sq = ex * ex + ey * ey;
156
157 if dist_sq < min_dist_sq {
158 min_dist_sq = dist_sq;
159 closest_point = geo::Coord {
160 x: proj_x,
161 y: proj_y,
162 };
163 best_seg = i;
164 best_t = t;
165 }
166 }
167
168 let closest_pt = Point::new(closest_point.x, closest_point.y);
169
170 let distance = point.haversine_distance(&closest_pt);
172
173 let mut length_before = 0.0;
176 for i in 0..best_seg {
177 let a = Point::new(coords[i].x, coords[i].y);
178 let b = Point::new(coords[i + 1].x, coords[i + 1].y);
179 length_before += a.haversine_distance(&b);
180 }
181 let seg_start = Point::new(coords[best_seg].x, coords[best_seg].y);
182 let seg_end = Point::new(coords[best_seg + 1].x, coords[best_seg + 1].y);
183 let seg_length = seg_start.haversine_distance(&seg_end);
184 length_before += best_t * seg_length;
185
186 let total_length: f64 = (0..coords.len() - 1)
187 .map(|i| {
188 let a = Point::new(coords[i].x, coords[i].y);
189 let b = Point::new(coords[i + 1].x, coords[i + 1].y);
190 a.haversine_distance(&b)
191 })
192 .sum();
193
194 let intrinsic = if total_length > 0.0 {
195 (length_before / total_length).clamp(0.0, 1.0)
196 } else {
197 0.0
198 };
199
200 Ok((distance, intrinsic, closest_pt))
201}
202
203pub fn calculate_heading_at_point(
221 point: &Point<f64>,
222 linestring: &LineString<f64>,
223) -> Result<f64, ProjectionError> {
224 let coords: Vec<_> = linestring.points().collect();
225
226 if coords.len() < 2 {
227 return Ok(0.0);
228 }
229
230 let cos_lat = point.y().to_radians().cos();
233 let px = point.x() * 111_320.0 * cos_lat;
234 let py = point.y() * 111_320.0;
235
236 let mut closest_segment_idx = 0;
237 let mut closest_distance = f64::MAX;
238
239 for i in 0..coords.len() - 1 {
240 let ax = coords[i].x() * 111_320.0 * cos_lat;
241 let ay = coords[i].y() * 111_320.0;
242 let bx = coords[i + 1].x() * 111_320.0 * cos_lat;
243 let by = coords[i + 1].y() * 111_320.0;
244
245 let dx = bx - ax;
246 let dy = by - ay;
247 let seg_len_sq = dx * dx + dy * dy;
248
249 let t = if seg_len_sq < 1e-18 {
251 0.0
252 } else {
253 ((px - ax) * dx + (py - ay) * dy) / seg_len_sq
254 }
255 .clamp(0.0, 1.0);
256
257 let proj_x = ax + t * dx;
258 let proj_y = ay + t * dy;
259 let dist = ((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt();
260
261 if dist < closest_distance {
262 closest_distance = dist;
263 closest_segment_idx = i;
264 }
265 }
266
267 let p1 = coords[closest_segment_idx];
270 let p2 = coords[closest_segment_idx + 1];
271
272 Ok(haversine_bearing(
273 &Point::new(p1.x(), p1.y()),
274 &Point::new(p2.x(), p2.y()),
275 ))
276}
277
278pub fn estimate_headings_from_neighbors(positions: &[&GnssPosition]) -> Vec<Option<f64>> {
291 let n = positions.len();
292 let mut headings: Vec<Option<f64>> = vec![None; n];
293
294 if n < 3 {
295 return headings;
296 }
297
298 for x in 1..n - 1 {
300 let prev = Point::new(positions[x - 1].longitude, positions[x - 1].latitude);
301 let curr = Point::new(positions[x].longitude, positions[x].latitude);
302 let next = Point::new(positions[x + 1].longitude, positions[x + 1].latitude);
303
304 let d_prev = haversine_distance(&prev, &curr);
305 let d_next = haversine_distance(&curr, &next);
306
307 let max_d = d_prev.max(d_next);
309 if max_d < 1e-9 {
310 continue; }
312 if (d_prev - d_next).abs() / max_d >= 0.20 {
313 continue;
314 }
315
316 let h_back = haversine_bearing(&prev, &curr);
321 let h_fwd = haversine_bearing(&curr, &next);
322 if directional_heading_difference(h_back, h_fwd) >= 5.0 {
323 continue;
324 }
325
326 headings[x] = Some(haversine_bearing(&prev, &next));
327 }
328
329 let raw_headings = headings.clone();
333 for x in 2..n - 1 {
334 if let Some(h) = headings[x] {
335 if let Some(prev_h) = raw_headings[x - 1] {
336 if heading_difference(h, prev_h) >= 5.0 {
337 headings[x] = None;
338 }
339 }
340 }
341 }
342
343 headings
344}
345
346fn haversine_distance(a: &Point<f64>, b: &Point<f64>) -> f64 {
348 let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
349 let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
350 let dlat = lat2 - lat1;
351 let dlon = lon2 - lon1;
352 let h = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
353 2.0 * 6_371_000.0 * h.sqrt().asin()
354}
355
356pub(crate) fn haversine_bearing(a: &Point<f64>, b: &Point<f64>) -> f64 {
358 let (lat1, lon1) = (a.y().to_radians(), a.x().to_radians());
359 let (lat2, lon2) = (b.y().to_radians(), b.x().to_radians());
360 let dlon = lon2 - lon1;
361 let x = dlon.sin() * lat2.cos();
362 let y = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlon.cos();
363 (x.atan2(y).to_degrees() + 360.0) % 360.0
364}
365
366pub fn heading_difference(heading1: f64, heading2: f64) -> f64 {
382 let diff = (heading1 - heading2).abs() % 360.0;
383
384 let diff = if diff > 180.0 { 360.0 - diff } else { diff };
386
387 if diff > 90.0 {
389 180.0 - diff
390 } else {
391 diff
392 }
393}
394
395pub(crate) fn directional_heading_difference(heading1: f64, heading2: f64) -> f64 {
404 let diff = (heading1 - heading2).abs() % 360.0;
405 if diff > 180.0 {
406 360.0 - diff
407 } else {
408 diff
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use geo::LineString;
416
417 #[test]
418 fn test_heading_difference_same_direction() {
419 let diff = heading_difference(45.0, 45.0);
420 assert_eq!(diff, 0.0);
421 }
422
423 #[test]
424 fn test_heading_difference_opposite_direction() {
425 let diff = heading_difference(0.0, 180.0);
427 assert_eq!(diff, 0.0);
428 }
429
430 #[test]
431 fn test_heading_difference_wraparound() {
432 let diff = heading_difference(350.0, 10.0);
434 assert_eq!(diff, 20.0);
435 }
436
437 #[test]
438 fn test_heading_difference_perpendicular() {
439 let diff = heading_difference(0.0, 90.0);
440 assert_eq!(diff, 90.0);
441 }
442
443 #[test]
444 fn test_heading_difference_near_opposite() {
445 let diff = heading_difference(0.0, 170.0);
447 assert_eq!(diff, 10.0);
448 }
449
450 #[test]
451 fn test_candidate_selection_within_cutoff() {
452 let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
454 let netelement =
455 Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
456
457 let gnss = GnssPosition::new(
459 50.8502,
460 4.3502,
461 chrono::Utc::now().into(),
462 "EPSG:4326".to_string(),
463 )
464 .unwrap();
465
466 let candidates = find_candidate_netelements(&gnss, &[netelement], 50.0, 10).unwrap();
468
469 assert_eq!(candidates.len(), 1);
471 assert_eq!(candidates[0].netelement_id, "NE_TEST");
472 assert!(candidates[0].distance_meters < 50.0);
473 }
474
475 #[test]
476 fn test_candidate_selection_beyond_cutoff() {
477 let linestring = LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]);
478 let netelement =
479 Netelement::new("NE_TEST".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
480
481 let gnss = GnssPosition::new(
483 50.90,
484 4.40,
485 chrono::Utc::now().into(),
486 "EPSG:4326".to_string(),
487 )
488 .unwrap();
489
490 let candidates = find_candidate_netelements(&gnss, &[netelement], 1.0, 10).unwrap();
492
493 assert_eq!(candidates.len(), 0);
495 }
496
497 #[test]
498 fn test_candidate_max_candidates_limit() {
499 let netelements = vec![
501 Netelement::new(
502 "NE_1".to_string(),
503 LineString::from(vec![(4.350, 50.850), (4.351, 50.851)]),
504 "EPSG:4326".to_string(),
505 )
506 .unwrap(),
507 Netelement::new(
508 "NE_2".to_string(),
509 LineString::from(vec![(4.3502, 50.8502), (4.3512, 50.8512)]),
510 "EPSG:4326".to_string(),
511 )
512 .unwrap(),
513 Netelement::new(
514 "NE_3".to_string(),
515 LineString::from(vec![(4.3503, 50.8503), (4.3513, 50.8513)]),
516 "EPSG:4326".to_string(),
517 )
518 .unwrap(),
519 ];
520
521 let gnss = GnssPosition::new(
522 50.8502,
523 4.3502,
524 chrono::Utc::now().into(),
525 "EPSG:4326".to_string(),
526 )
527 .unwrap();
528
529 let candidates = find_candidate_netelements(&gnss, &netelements, 500.0, 2).unwrap();
531
532 assert!(candidates.len() <= 2);
534 }
535
536 #[test]
539 fn test_edge_projection_at_start_kept_as_fallback() {
540 let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
544 let netelement =
545 Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
546
547 let gnss = GnssPosition::new(
548 50.850,
549 4.350,
550 chrono::Utc::now().into(),
551 "EPSG:4326".to_string(),
552 )
553 .unwrap();
554
555 let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
556 assert_eq!(
557 candidates.len(),
558 1,
559 "Edge candidate kept as fallback when no interior candidates exist"
560 );
561 }
562
563 #[test]
564 fn test_edge_projection_at_end_kept_as_fallback() {
565 let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
568 let netelement =
569 Netelement::new("NE_EDGE".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
570
571 let gnss = GnssPosition::new(
572 50.850,
573 4.360,
574 chrono::Utc::now().into(),
575 "EPSG:4326".to_string(),
576 )
577 .unwrap();
578
579 let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
580 assert_eq!(
581 candidates.len(),
582 1,
583 "Edge candidate kept as fallback when no interior candidates exist"
584 );
585 }
586
587 #[test]
588 fn test_mid_range_projection_accepted() {
589 let linestring = LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]);
591 let netelement =
592 Netelement::new("NE_MID".to_string(), linestring, "EPSG:4326".to_string()).unwrap();
593
594 let gnss = GnssPosition::new(
595 50.850,
596 4.355,
597 chrono::Utc::now().into(),
598 "EPSG:4326".to_string(),
599 )
600 .unwrap();
601
602 let candidates = find_candidate_netelements(&gnss, &[netelement], 500.0, 10).unwrap();
603 assert_eq!(candidates.len(), 1);
604 assert!(
605 candidates[0].intrinsic_coordinate > 0.1 && candidates[0].intrinsic_coordinate < 0.9,
606 "Midpoint candidate should have intrinsic near 0.5"
607 );
608 }
609
610 #[test]
611 fn test_edge_candidate_removed_when_interior_exists() {
612 let ne_interior = Netelement::new(
615 "NE_INT".to_string(),
616 LineString::from(vec![(4.350, 50.850), (4.360, 50.850)]),
617 "EPSG:4326".to_string(),
618 )
619 .unwrap();
620 let ne_edge = Netelement::new(
621 "NE_EDGE".to_string(),
622 LineString::from(vec![(4.353, 50.851), (4.355, 50.851)]),
623 "EPSG:4326".to_string(),
624 )
625 .unwrap();
626
627 let gnss = GnssPosition::new(
629 50.850,
630 4.355,
631 chrono::Utc::now().into(),
632 "EPSG:4326".to_string(),
633 )
634 .unwrap();
635
636 let candidates =
637 find_candidate_netelements(&gnss, &[ne_interior, ne_edge], 500.0, 10).unwrap();
638 assert!(
639 candidates.iter().all(|c| c.netelement_id != "NE_EDGE"
640 || (c.intrinsic_coordinate > 1e-6 && c.intrinsic_coordinate < 1.0 - 1e-6)),
641 "Edge candidates should be removed when interior candidates exist"
642 );
643 }
644
645 fn make_gnss(lat: f64, lon: f64) -> GnssPosition {
648 GnssPosition::new(lat, lon, chrono::Utc::now().into(), "EPSG:4326".to_string()).unwrap()
649 }
650
651 #[test]
652 fn test_estimate_headings_straight_north() {
653 let positions = [
655 make_gnss(50.000, 4.000),
656 make_gnss(50.001, 4.000),
657 make_gnss(50.002, 4.000),
658 ];
659 let refs: Vec<&GnssPosition> = positions.iter().collect();
660 let headings = estimate_headings_from_neighbors(&refs);
661
662 assert!(headings[0].is_none(), "First position should be None");
663 assert!(headings[2].is_none(), "Last position should be None");
664 let h = headings[1].expect("Middle position should have estimated heading");
665 assert!(!(1.0..=359.0).contains(&h), "Expected ~0° north, got {h}");
667 }
668
669 #[test]
670 fn test_estimate_headings_straight_east() {
671 let positions = [
673 make_gnss(50.000, 4.000),
674 make_gnss(50.000, 4.001),
675 make_gnss(50.000, 4.002),
676 ];
677 let refs: Vec<&GnssPosition> = positions.iter().collect();
678 let headings = estimate_headings_from_neighbors(&refs);
679
680 let h = headings[1].expect("Middle position should have estimated heading");
681 assert!((h - 90.0).abs() < 1.0, "Expected ~90° east, got {h}");
682 }
683
684 #[test]
685 fn test_estimate_headings_endpoints_none() {
686 let positions = [make_gnss(50.000, 4.000), make_gnss(50.001, 4.000)];
687 let refs: Vec<&GnssPosition> = positions.iter().collect();
688 let headings = estimate_headings_from_neighbors(&refs);
689 assert!(
690 headings.iter().all(|h| h.is_none()),
691 "With < 3 positions all should be None"
692 );
693 }
694
695 #[test]
696 fn test_estimate_headings_unequal_spacing() {
697 let positions = [
699 make_gnss(50.000, 4.000),
700 make_gnss(50.010, 4.000), make_gnss(50.0101, 4.000), ];
703 let refs: Vec<&GnssPosition> = positions.iter().collect();
704 let headings = estimate_headings_from_neighbors(&refs);
705 assert!(
706 headings[1].is_none(),
707 "Unequal spacing should reject estimated heading"
708 );
709 }
710
711 #[test]
712 fn test_estimate_headings_continuity_rejection() {
713 let positions = [
716 make_gnss(50.000, 4.000),
717 make_gnss(50.001, 4.000), make_gnss(50.002, 4.000), make_gnss(50.002, 4.001), make_gnss(50.002, 4.002),
721 ];
722 let refs: Vec<&GnssPosition> = positions.iter().collect();
723 let headings = estimate_headings_from_neighbors(&refs);
724
725 assert!(headings[1].is_some(), "Position 1 heading should be valid");
727 let has_rejection = headings[2].is_none() || headings[3].is_none();
729 assert!(
730 has_rejection,
731 "Sharp turn should cause at least one heading rejection"
732 );
733 }
734}