1use crate::astro::math::special::erf;
8use crate::dop::{self, PositionCovariance};
9use crate::error_metrics::{self, PercentileRadius};
10use crate::frame::Wgs84Geodetic;
11use crate::geodesic::{geodesic_direct, geodesic_inverse, GeodesicError};
12
13const DEG_TO_RAD: f64 = core::f64::consts::PI / 180.0;
14const RAD_TO_DEG: f64 = 180.0 / core::f64::consts::PI;
15const TWO_PI: f64 = 2.0 * core::f64::consts::PI;
16const EDGE_MINIMIZE_ITERS: usize = 64;
17const PSD_REL_TOL: f64 = 1.0e-12;
18const RAY_EPS: f64 = 1.0e-12;
19const RAY_PROBE: f64 = 1.0e-7;
20const DEDUP_EPS: f64 = 1.0e-9;
21const ORIENTATION_PROBE_MAX_M: f64 = 1.0;
22const ORIENTATION_PROBE_FRACTION: f64 = 1.0e-3;
23const ORIENTATION_CENTROID_NORM_TOL: f64 = 1.0e-12;
24
25pub const PLANAR_FAST_PATH_MAX_RADIUS_M: f64 = 50_000.0;
31
32pub const GEOFENCE_BOUNDARY_TOLERANCE_M: f64 = 1.0e-4;
34
35#[allow(clippy::excessive_precision)]
36const GL64_POSITIVE_NODES: [f64; 32] = [
37 2.43502926634244325e-02,
38 7.29931217877990424e-02,
39 1.21462819296120544e-01,
40 1.69644420423992831e-01,
41 2.17423643740007083e-01,
42 2.64687162208767424e-01,
43 3.11322871990210970e-01,
44 3.57220158337668126e-01,
45 4.02270157963991570e-01,
46 4.46366017253464087e-01,
47 4.89403145707052956e-01,
48 5.31279464019894565e-01,
49 5.71895646202634000e-01,
50 6.11155355172393278e-01,
51 6.48965471254657311e-01,
52 6.85236313054233270e-01,
53 7.19881850171610771e-01,
54 7.52819907260531940e-01,
55 7.83972358943341385e-01,
56 8.13265315122797539e-01,
57 8.40629296252580316e-01,
58 8.65999398154092770e-01,
59 8.89315445995114140e-01,
60 9.10522137078502825e-01,
61 9.29569172131939570e-01,
62 9.46411374858402765e-01,
63 9.61008799652053658e-01,
64 9.73326827789910975e-01,
65 9.83336253884625977e-01,
66 9.91013371476744287e-01,
67 9.96340116771955220e-01,
68 9.99305041735772170e-01,
69];
70
71#[allow(clippy::excessive_precision)]
72const GL64_POSITIVE_WEIGHTS: [f64; 32] = [
73 4.86909570091397237e-02,
74 4.85754674415033935e-02,
75 4.83447622348029404e-02,
76 4.79993885964583034e-02,
77 4.75401657148303222e-02,
78 4.69681828162099788e-02,
79 4.62847965813143539e-02,
80 4.54916279274180727e-02,
81 4.45905581637566079e-02,
82 4.35837245293234157e-02,
83 4.24735151236535352e-02,
84 4.12625632426234581e-02,
85 3.99537411327204536e-02,
86 3.85501531786155635e-02,
87 3.70551285402399844e-02,
88 3.54722132568822401e-02,
89 3.38051618371418630e-02,
90 3.20579283548514254e-02,
91 3.02346570724024884e-02,
92 2.83396726142594486e-02,
93 2.63774697150548978e-02,
94 2.43527025687111306e-02,
95 2.22701738083829967e-02,
96 2.01348231535300216e-02,
97 1.79517157756973571e-02,
98 1.57260304760250269e-02,
99 1.34630478967191179e-02,
100 1.11681394601309738e-02,
101 8.84675982636339009e-03,
102 6.50445796897848854e-03,
103 4.14703326056443250e-03,
104 1.78328072169469699e-03,
105];
106
107#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
109pub enum GeofenceError {
110 #[error("geofence needs at least three vertices")]
112 TooFewVertices,
113 #[error("invalid geofence input {field}: {reason}")]
115 InvalidInput {
116 field: &'static str,
118 reason: &'static str,
120 },
121 #[error(transparent)]
123 Geodesic(#[from] GeodesicError),
124 #[error("covariance rotation failed")]
126 Dop(#[from] dop::DopError),
127 #[error("uncertainty validation failed")]
129 ErrorMetrics(error_metrics::ErrorMetricsError),
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct Fence {
135 vertices: Vec<Wgs84Geodetic>,
136 edges: Vec<GeodesicEdge>,
137 interior_winding_sign: f64,
138 planar: Option<PlanarCache>,
139}
140
141impl Fence {
142 pub fn new<I>(vertices: I) -> Result<Self, GeofenceError>
148 where
149 I: IntoIterator<Item = Wgs84Geodetic>,
150 {
151 let mut vertices: Vec<Wgs84Geodetic> = vertices.into_iter().collect();
152 if vertices.len() >= 2
153 && same_horizontal_position(vertices[0], vertices[vertices.len() - 1])?
154 {
155 vertices.pop();
156 }
157 if vertices.len() < 3 {
158 return Err(GeofenceError::TooFewVertices);
159 }
160 validate_distinct_vertices(&vertices)?;
161
162 let mut edges = Vec::with_capacity(vertices.len());
163 for idx in 0..vertices.len() {
164 let start = vertices[idx];
165 let end = vertices[(idx + 1) % vertices.len()];
166 let (length_m, azimuth_deg, _) = inverse_points(start, end)?;
167 if length_m == 0.0 {
168 return Err(invalid_input("vertices", "adjacent vertices must differ"));
169 }
170 edges.push(GeodesicEdge {
171 start,
172 length_m,
173 azimuth_deg,
174 });
175 }
176
177 let interior_winding_sign = interior_winding_sign(&vertices, &edges)?;
178 let planar = build_planar_cache(&vertices, &edges)?;
179 Ok(Self {
180 vertices,
181 edges,
182 interior_winding_sign,
183 planar,
184 })
185 }
186
187 pub fn vertices(&self) -> &[Wgs84Geodetic] {
189 &self.vertices
190 }
191
192 pub fn edge_count(&self) -> usize {
194 self.edges.len()
195 }
196
197 pub fn planar_fast_path_applies(&self, position: Wgs84Geodetic) -> bool {
199 self.planar
200 .as_ref()
201 .and_then(|cache| project_from(cache.anchor, position).ok())
202 .is_some_and(|projected| norm2(projected) <= PLANAR_FAST_PATH_MAX_RADIUS_M)
203 }
204
205 pub fn contains(&self, position: Wgs84Geodetic) -> Result<bool, GeofenceError> {
210 Ok(self.distance_to_boundary(position)? >= -GEOFENCE_BOUNDARY_TOLERANCE_M)
211 }
212
213 pub fn distance_to_boundary(&self, position: Wgs84Geodetic) -> Result<f64, GeofenceError> {
218 Ok(self.boundary_distance_geodesic(position)?.signed_distance_m)
219 }
220
221 pub fn distance_to_boundary_planar_fast(
228 &self,
229 position: Wgs84Geodetic,
230 ) -> Result<Option<f64>, GeofenceError> {
231 if let Some(cache) = &self.planar {
232 if let Ok(point) = project_from(cache.anchor, position) {
233 if norm2(point) <= PLANAR_FAST_PATH_MAX_RADIUS_M {
234 return Ok(Some(
235 boundary_distance_planar(&cache.vertices_m, point).signed_distance_m,
236 ));
237 }
238 }
239 }
240 Ok(None)
241 }
242
243 fn boundary_distance(
244 &self,
245 position: Wgs84Geodetic,
246 ) -> Result<BoundaryDistance, GeofenceError> {
247 self.boundary_distance_geodesic(position)
248 }
249
250 fn boundary_distance_geodesic(
251 &self,
252 position: Wgs84Geodetic,
253 ) -> Result<BoundaryDistance, GeofenceError> {
254 let mut nearest = None;
255 for (edge_index, edge) in self.edges.iter().enumerate() {
256 let candidate = nearest_on_edge(position, *edge, edge_index)?;
257 if nearest
258 .as_ref()
259 .is_none_or(|closest: &NearestBoundary| candidate.distance_m < closest.distance_m)
260 {
261 nearest = Some(candidate);
262 }
263 }
264 let nearest = nearest.ok_or(GeofenceError::TooFewVertices)?;
265 if nearest.distance_m <= GEOFENCE_BOUNDARY_TOLERANCE_M {
266 return Ok(BoundaryDistance {
267 signed_distance_m: 0.0,
268 normal_en: tangent_normal(self.edges[nearest.edge_index].azimuth_deg),
269 });
270 }
271
272 let inside = contains_by_winding(position, &self.vertices, self.interior_winding_sign)?;
273 let sign = if inside { 1.0 } else { -1.0 };
274 let normal_en =
275 normal_from_position_to_boundary(position, nearest.point, nearest.distance_m)
276 .unwrap_or_else(|_| tangent_normal(self.edges[nearest.edge_index].azimuth_deg));
277 Ok(BoundaryDistance {
278 signed_distance_m: sign * nearest.distance_m,
279 normal_en,
280 })
281 }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq)]
286pub enum PositionUncertainty {
287 EnuCovarianceM2([[f64; 3]; 3]),
289 EcefCovarianceM2([[f64; 3]; 3]),
291 PositionCovariance(PositionCovariance),
293 HorizontalRadius(PercentileRadius),
295 CepRadiusM(f64),
297}
298
299impl From<PositionCovariance> for PositionUncertainty {
300 fn from(value: PositionCovariance) -> Self {
301 Self::PositionCovariance(value)
302 }
303}
304
305impl From<PercentileRadius> for PositionUncertainty {
306 fn from(value: PercentileRadius) -> Self {
307 Self::HorizontalRadius(value)
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ProbabilityMethod {
314 BoundaryNormal,
316 PlanarQuadrature,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub struct ProbabilityOptions {
323 pub method: ProbabilityMethod,
325}
326
327impl Default for ProbabilityOptions {
328 fn default() -> Self {
329 Self {
330 method: ProbabilityMethod::BoundaryNormal,
331 }
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq)]
337pub struct GeofencePositionEstimate {
338 pub position: Wgs84Geodetic,
340 pub uncertainty: PositionUncertainty,
342}
343
344#[derive(Debug, Clone, Copy, PartialEq)]
346pub struct ProbabilityHysteresis {
347 pub enter_confidence: f64,
349 pub leave_confidence: f64,
351}
352
353impl ProbabilityHysteresis {
354 pub fn new(enter_confidence: f64, leave_confidence: f64) -> Result<Self, GeofenceError> {
359 validate_confidence("enter_confidence", enter_confidence)?;
360 validate_confidence("leave_confidence", leave_confidence)?;
361 Ok(Self {
362 enter_confidence,
363 leave_confidence,
364 })
365 }
366}
367
368impl Default for ProbabilityHysteresis {
369 fn default() -> Self {
370 Self {
371 enter_confidence: 0.95,
372 leave_confidence: 0.95,
373 }
374 }
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum CrossingKind {
380 Entered,
382 Left,
384}
385
386#[derive(Debug, Clone, Copy, PartialEq)]
388pub struct CrossingEvent {
389 pub sample_index: usize,
391 pub kind: CrossingKind,
393 pub inside_probability: f64,
395}
396
397pub fn containment(position: Wgs84Geodetic, fence: &Fence) -> Result<bool, GeofenceError> {
399 fence.contains(position)
400}
401
402pub fn distance_to_boundary(position: Wgs84Geodetic, fence: &Fence) -> Result<f64, GeofenceError> {
404 fence.distance_to_boundary(position)
405}
406
407pub fn containment_probability(
414 position: Wgs84Geodetic,
415 uncertainty: PositionUncertainty,
416 fence: &Fence,
417) -> Result<f64, GeofenceError> {
418 containment_probability_with_options(
419 position,
420 uncertainty,
421 fence,
422 ProbabilityOptions::default(),
423 )
424}
425
426pub fn containment_probability_with_options(
428 position: Wgs84Geodetic,
429 uncertainty: PositionUncertainty,
430 fence: &Fence,
431 options: ProbabilityOptions,
432) -> Result<f64, GeofenceError> {
433 let boundary = fence.boundary_distance(position)?;
434 let covariance = uncertainty_to_enu_m2(uncertainty, position)?;
435 match options.method {
436 ProbabilityMethod::BoundaryNormal => {
437 probability_boundary_normal_or_quadrature(fence, position, boundary, covariance)
438 }
439 ProbabilityMethod::PlanarQuadrature => {
440 probability_planar_quadrature(fence, position, boundary, covariance)
441 }
442 }
443}
444
445pub fn crossing(
447 positions: &[Wgs84Geodetic],
448 fence: &Fence,
449) -> Result<Vec<CrossingEvent>, GeofenceError> {
450 let mut events = Vec::new();
451 let Some((&first, rest)) = positions.split_first() else {
452 return Ok(events);
453 };
454 let mut inside = fence.contains(first)?;
455 for (offset, &position) in rest.iter().enumerate() {
456 let next_inside = fence.contains(position)?;
457 if next_inside != inside {
458 events.push(CrossingEvent {
459 sample_index: offset + 1,
460 kind: if next_inside {
461 CrossingKind::Entered
462 } else {
463 CrossingKind::Left
464 },
465 inside_probability: if next_inside { 1.0 } else { 0.0 },
466 });
467 inside = next_inside;
468 }
469 }
470 Ok(events)
471}
472
473pub fn crossing_probability(
475 samples: &[GeofencePositionEstimate],
476 fence: &Fence,
477 hysteresis: ProbabilityHysteresis,
478) -> Result<Vec<CrossingEvent>, GeofenceError> {
479 crossing_probability_with_options(samples, fence, hysteresis, ProbabilityOptions::default())
480}
481
482pub fn crossing_probability_with_options(
484 samples: &[GeofencePositionEstimate],
485 fence: &Fence,
486 hysteresis: ProbabilityHysteresis,
487 options: ProbabilityOptions,
488) -> Result<Vec<CrossingEvent>, GeofenceError> {
489 validate_confidence("enter_confidence", hysteresis.enter_confidence)?;
490 validate_confidence("leave_confidence", hysteresis.leave_confidence)?;
491 let mut events = Vec::new();
492 let Some((first, rest)) = samples.split_first() else {
493 return Ok(events);
494 };
495 let first_probability =
496 containment_probability_with_options(first.position, first.uncertainty, fence, options)?;
497 let outside_threshold = 1.0 - hysteresis.leave_confidence;
498 let mut state = HysteresisState::from_probability(
499 first_probability,
500 hysteresis.enter_confidence,
501 outside_threshold,
502 );
503
504 for (offset, sample) in rest.iter().enumerate() {
505 let probability = containment_probability_with_options(
506 sample.position,
507 sample.uncertainty,
508 fence,
509 options,
510 )?;
511 match state {
512 HysteresisState::Unknown => {
513 state = HysteresisState::from_probability(
514 probability,
515 hysteresis.enter_confidence,
516 outside_threshold,
517 );
518 }
519 HysteresisState::Outside if probability >= hysteresis.enter_confidence => {
520 state = HysteresisState::Inside;
521 events.push(CrossingEvent {
522 sample_index: offset + 1,
523 kind: CrossingKind::Entered,
524 inside_probability: probability,
525 });
526 }
527 HysteresisState::Inside if probability <= outside_threshold => {
528 state = HysteresisState::Outside;
529 events.push(CrossingEvent {
530 sample_index: offset + 1,
531 kind: CrossingKind::Left,
532 inside_probability: probability,
533 });
534 }
535 _ => {}
536 }
537 }
538 Ok(events)
539}
540
541#[derive(Debug, Clone, Copy, PartialEq)]
542struct GeodesicEdge {
543 start: Wgs84Geodetic,
544 length_m: f64,
545 azimuth_deg: f64,
546}
547
548#[derive(Debug, Clone, PartialEq)]
549struct PlanarCache {
550 anchor: Wgs84Geodetic,
551 vertices_m: Vec<[f64; 2]>,
552}
553
554#[derive(Debug, Clone, Copy)]
555struct BoundaryDistance {
556 signed_distance_m: f64,
557 normal_en: [f64; 2],
558}
559
560#[derive(Debug, Clone, Copy)]
561struct NearestBoundary {
562 distance_m: f64,
563 point: Wgs84Geodetic,
564 edge_index: usize,
565}
566
567#[derive(Debug, Clone, Copy)]
568struct HorizontalEigen {
569 major_m2: f64,
570 minor_m2: f64,
571 major_axis: [f64; 2],
572 minor_axis: [f64; 2],
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum HysteresisState {
577 Unknown,
578 Inside,
579 Outside,
580}
581
582impl HysteresisState {
583 fn from_probability(probability: f64, enter_confidence: f64, outside_threshold: f64) -> Self {
584 if probability >= enter_confidence {
585 Self::Inside
586 } else if probability <= outside_threshold {
587 Self::Outside
588 } else {
589 Self::Unknown
590 }
591 }
592}
593
594fn invalid_input(field: &'static str, reason: &'static str) -> GeofenceError {
595 GeofenceError::InvalidInput { field, reason }
596}
597
598fn validate_probability(field: &'static str, probability: f64) -> Result<(), GeofenceError> {
599 if probability.is_finite() && probability > 0.0 && probability < 1.0 {
600 Ok(())
601 } else {
602 Err(invalid_input(field, "must be in (0, 1)"))
603 }
604}
605
606fn validate_confidence(field: &'static str, probability: f64) -> Result<(), GeofenceError> {
607 if probability.is_finite() && probability > 0.5 && probability < 1.0 {
608 Ok(())
609 } else {
610 Err(invalid_input(field, "must be in (0.5, 1)"))
611 }
612}
613
614fn same_horizontal_position(a: Wgs84Geodetic, b: Wgs84Geodetic) -> Result<bool, GeofenceError> {
615 if a.lat_rad.to_bits() == b.lat_rad.to_bits() && a.lon_rad.to_bits() == b.lon_rad.to_bits() {
616 return Ok(true);
617 }
618 let (distance_m, _, _) = inverse_points(a, b)?;
619 Ok(distance_m <= GEOFENCE_BOUNDARY_TOLERANCE_M)
620}
621
622fn validate_distinct_vertices(vertices: &[Wgs84Geodetic]) -> Result<(), GeofenceError> {
623 let mut distinct_count = 0;
624 'vertex: for (idx, &vertex) in vertices.iter().enumerate() {
625 for &previous in &vertices[..idx] {
626 if same_horizontal_position(vertex, previous)? {
627 continue 'vertex;
628 }
629 }
630 distinct_count += 1;
631 }
632 if distinct_count < 3 {
633 return Err(GeofenceError::TooFewVertices);
634 }
635 if distinct_count != vertices.len() {
636 return Err(invalid_input("vertices", "vertices must be distinct"));
637 }
638 Ok(())
639}
640
641fn interior_winding_sign(
642 vertices: &[Wgs84Geodetic],
643 edges: &[GeodesicEdge],
644) -> Result<f64, GeofenceError> {
645 let mut xyz = [0.0_f64; 3];
646 for &vertex in vertices {
647 let cos_lat = vertex.lat_rad.cos();
648 xyz[0] += cos_lat * vertex.lon_rad.cos();
649 xyz[1] += cos_lat * vertex.lon_rad.sin();
650 xyz[2] += vertex.lat_rad.sin();
651 }
652 let norm = (xyz[0] * xyz[0] + xyz[1] * xyz[1] + xyz[2] * xyz[2]).sqrt();
653 if norm > ORIENTATION_CENTROID_NORM_TOL {
654 let lat = (xyz[2] / norm).asin();
655 let lon = xyz[1].atan2(xyz[0]);
656 let probe = Wgs84Geodetic::new(lat, lon, 0.0)
657 .map_err(|_| invalid_input("vertices", "centroid is outside geodetic range"))?;
658 let sum = winding_sum(probe, vertices)?;
659 if sum.abs() > core::f64::consts::PI {
660 return Ok(sum.signum());
661 }
662 }
663
664 for edge in edges {
665 if let Some(sign) = edge_orientation_probe(vertices, *edge)? {
666 return Ok(sign);
667 }
668 }
669 Err(invalid_input(
670 "vertices",
671 "interior orientation is ambiguous",
672 ))
673}
674
675fn edge_orientation_probe(
676 vertices: &[Wgs84Geodetic],
677 edge: GeodesicEdge,
678) -> Result<Option<f64>, GeofenceError> {
679 let midpoint = direct_point(edge.start, edge.azimuth_deg, 0.5 * edge.length_m)?;
680 let offset_m = (edge.length_m * ORIENTATION_PROBE_FRACTION).clamp(
681 GEOFENCE_BOUNDARY_TOLERANCE_M * 10.0,
682 ORIENTATION_PROBE_MAX_M,
683 );
684 let left = direct_point(midpoint, edge.azimuth_deg - 90.0, offset_m)?;
685 let right = direct_point(midpoint, edge.azimuth_deg + 90.0, offset_m)?;
686 let left_sum = winding_sum(left, vertices)?;
687 let right_sum = winding_sum(right, vertices)?;
688 let left_inside = left_sum.abs() > core::f64::consts::PI;
689 let right_inside = right_sum.abs() > core::f64::consts::PI;
690 match (left_inside, right_inside) {
691 (true, false) => Ok(Some(left_sum.signum())),
692 (false, true) => Ok(Some(right_sum.signum())),
693 (true, true) => Err(invalid_input(
694 "vertices",
695 "interior orientation is ambiguous",
696 )),
697 (false, false) => Ok(None),
698 }
699}
700
701fn inverse_points(a: Wgs84Geodetic, b: Wgs84Geodetic) -> Result<(f64, f64, f64), GeofenceError> {
702 Ok(geodesic_inverse(
703 a.lat_rad * RAD_TO_DEG,
704 a.lon_rad * RAD_TO_DEG,
705 b.lat_rad * RAD_TO_DEG,
706 b.lon_rad * RAD_TO_DEG,
707 )?)
708}
709
710fn direct_point(
711 start: Wgs84Geodetic,
712 azimuth_deg: f64,
713 distance_m: f64,
714) -> Result<Wgs84Geodetic, GeofenceError> {
715 let (lat_deg, lon_deg, _) = geodesic_direct(
716 start.lat_rad * RAD_TO_DEG,
717 start.lon_rad * RAD_TO_DEG,
718 azimuth_deg,
719 distance_m,
720 )?;
721 Wgs84Geodetic::new(lat_deg * DEG_TO_RAD, lon_deg * DEG_TO_RAD, 0.0)
722 .map_err(|_| invalid_input("geodesic_direct", "returned invalid geodetic position"))
723}
724
725fn build_planar_cache(
726 vertices: &[Wgs84Geodetic],
727 edges: &[GeodesicEdge],
728) -> Result<Option<PlanarCache>, GeofenceError> {
729 if edges
730 .iter()
731 .any(|edge| edge.length_m > PLANAR_FAST_PATH_MAX_RADIUS_M)
732 {
733 return Ok(None);
734 }
735 let anchor = vertices[0];
736 let mut vertices_m = Vec::with_capacity(vertices.len());
737 for &vertex in vertices {
738 let projected = project_from(anchor, vertex)?;
739 if norm2(projected) > PLANAR_FAST_PATH_MAX_RADIUS_M {
740 return Ok(None);
741 }
742 vertices_m.push(projected);
743 }
744 Ok(Some(PlanarCache { anchor, vertices_m }))
745}
746
747fn project_from(origin: Wgs84Geodetic, point: Wgs84Geodetic) -> Result<[f64; 2], GeofenceError> {
748 let (distance_m, azimuth_deg, _) = inverse_points(origin, point)?;
749 if distance_m == 0.0 {
750 return Ok([0.0, 0.0]);
751 }
752 let azimuth_rad = azimuth_deg * DEG_TO_RAD;
753 Ok([
754 distance_m * azimuth_rad.sin(),
755 distance_m * azimuth_rad.cos(),
756 ])
757}
758
759fn boundary_distance_planar(vertices_m: &[[f64; 2]], point: [f64; 2]) -> BoundaryDistance {
760 let mut closest_distance = f64::INFINITY;
761 let mut closest_normal = [1.0, 0.0];
762 for idx in 0..vertices_m.len() {
763 let a = vertices_m[idx];
764 let b = vertices_m[(idx + 1) % vertices_m.len()];
765 let (distance, normal) = distance_to_segment_planar(point, a, b);
766 if distance < closest_distance {
767 closest_distance = distance;
768 closest_normal = normal;
769 }
770 }
771 if closest_distance <= GEOFENCE_BOUNDARY_TOLERANCE_M {
772 return BoundaryDistance {
773 signed_distance_m: 0.0,
774 normal_en: closest_normal,
775 };
776 }
777 let inside = point_in_polygon_planar(vertices_m, point);
778 BoundaryDistance {
779 signed_distance_m: if inside {
780 closest_distance
781 } else {
782 -closest_distance
783 },
784 normal_en: closest_normal,
785 }
786}
787
788fn distance_to_segment_planar(point: [f64; 2], a: [f64; 2], b: [f64; 2]) -> (f64, [f64; 2]) {
789 let ab = sub2(b, a);
790 let ap = sub2(point, a);
791 let denom = dot2(ab, ab);
792 let t = if denom == 0.0 {
793 0.0
794 } else {
795 (dot2(ap, ab) / denom).clamp(0.0, 1.0)
796 };
797 let nearest = [a[0] + t * ab[0], a[1] + t * ab[1]];
798 let delta = sub2(point, nearest);
799 let distance = norm2(delta);
800 if distance > 0.0 {
801 (distance, [delta[0] / distance, delta[1] / distance])
802 } else {
803 let tangent_norm = norm2(ab);
804 if tangent_norm == 0.0 {
805 (0.0, [1.0, 0.0])
806 } else {
807 (0.0, [-ab[1] / tangent_norm, ab[0] / tangent_norm])
808 }
809 }
810}
811
812fn nearest_on_edge(
813 position: Wgs84Geodetic,
814 edge: GeodesicEdge,
815 edge_index: usize,
816) -> Result<NearestBoundary, GeofenceError> {
817 let mut closest = nearest_at(edge, position, 0.0, edge_index)?;
818 let end = nearest_at(edge, position, edge.length_m, edge_index)?;
819 if end.distance_m < closest.distance_m {
820 closest = end;
821 }
822
823 let mut lo = 0.0;
824 let mut hi = edge.length_m;
825 for _ in 0..EDGE_MINIMIZE_ITERS {
826 let third = (hi - lo) / 3.0;
827 let left = lo + third;
828 let right = hi - third;
829 let left_distance = distance_at(edge, position, left)?;
830 let right_distance = distance_at(edge, position, right)?;
831 if left_distance < right_distance {
832 hi = right;
833 } else {
834 lo = left;
835 }
836 }
837 let mid = 0.5 * (lo + hi);
838 let interior = nearest_at(edge, position, mid, edge_index)?;
839 if interior.distance_m < closest.distance_m {
840 closest = interior;
841 }
842 Ok(closest)
843}
844
845fn nearest_at(
846 edge: GeodesicEdge,
847 position: Wgs84Geodetic,
848 along_m: f64,
849 edge_index: usize,
850) -> Result<NearestBoundary, GeofenceError> {
851 let point = direct_point(edge.start, edge.azimuth_deg, along_m)?;
852 let (distance_m, _, _) = inverse_points(position, point)?;
853 Ok(NearestBoundary {
854 distance_m,
855 point,
856 edge_index,
857 })
858}
859
860fn distance_at(
861 edge: GeodesicEdge,
862 position: Wgs84Geodetic,
863 along_m: f64,
864) -> Result<f64, GeofenceError> {
865 let point = direct_point(edge.start, edge.azimuth_deg, along_m)?;
866 let (distance_m, _, _) = inverse_points(position, point)?;
867 Ok(distance_m)
868}
869
870fn normal_from_position_to_boundary(
871 position: Wgs84Geodetic,
872 boundary: Wgs84Geodetic,
873 distance_m: f64,
874) -> Result<[f64; 2], GeofenceError> {
875 if distance_m == 0.0 {
876 return Ok([1.0, 0.0]);
877 }
878 let (_, azimuth_deg, _) = inverse_points(position, boundary)?;
879 let azimuth_rad = azimuth_deg * DEG_TO_RAD;
880 Ok([azimuth_rad.sin(), azimuth_rad.cos()])
881}
882
883fn tangent_normal(azimuth_deg: f64) -> [f64; 2] {
884 let azimuth_rad = azimuth_deg * DEG_TO_RAD;
885 let tangent = [azimuth_rad.sin(), azimuth_rad.cos()];
886 [-tangent[1], tangent[0]]
887}
888
889fn contains_by_winding(
890 position: Wgs84Geodetic,
891 vertices: &[Wgs84Geodetic],
892 interior_sign: f64,
893) -> Result<bool, GeofenceError> {
894 let sum = winding_sum(position, vertices)?;
895 if sum.abs() <= core::f64::consts::PI {
896 return Ok(false);
897 }
898 if interior_sign != 0.0 {
899 Ok(sum.signum() == interior_sign)
900 } else {
901 Ok(false)
902 }
903}
904
905fn winding_sum(position: Wgs84Geodetic, vertices: &[Wgs84Geodetic]) -> Result<f64, GeofenceError> {
906 let mut sum = 0.0;
907 for idx in 0..vertices.len() {
908 let a = vertices[idx];
909 let b = vertices[(idx + 1) % vertices.len()];
910 let (distance_a, azimuth_a_deg, _) = inverse_points(position, a)?;
911 let (distance_b, azimuth_b_deg, _) = inverse_points(position, b)?;
912 if distance_a <= GEOFENCE_BOUNDARY_TOLERANCE_M
913 || distance_b <= GEOFENCE_BOUNDARY_TOLERANCE_M
914 {
915 return Ok(TWO_PI);
916 }
917 sum += wrap_pi((azimuth_b_deg - azimuth_a_deg) * DEG_TO_RAD);
918 }
919 Ok(sum)
920}
921
922fn point_in_polygon_planar(vertices: &[[f64; 2]], point: [f64; 2]) -> bool {
923 let mut inside = false;
924 let mut prev = vertices[vertices.len() - 1];
925 for &curr in vertices {
926 let crosses = (curr[1] > point[1]) != (prev[1] > point[1]);
927 if crosses {
928 let x_at_y = curr[0] + (point[1] - curr[1]) * (prev[0] - curr[0]) / (prev[1] - curr[1]);
929 if point[0] < x_at_y {
930 inside = !inside;
931 }
932 }
933 prev = curr;
934 }
935 inside
936}
937
938fn uncertainty_to_enu_m2(
939 uncertainty: PositionUncertainty,
940 position: Wgs84Geodetic,
941) -> Result<[[f64; 3]; 3], GeofenceError> {
942 let covariance = match uncertainty {
943 PositionUncertainty::EnuCovarianceM2(covariance) => covariance,
944 PositionUncertainty::EcefCovarianceM2(covariance) => {
945 dop::rotate_covariance_ecef_to_enu_m2(covariance, position)?
946 }
947 PositionUncertainty::PositionCovariance(covariance) => covariance.enu_m2,
948 PositionUncertainty::HorizontalRadius(radius) => covariance_from_horizontal_radius(radius)?,
949 PositionUncertainty::CepRadiusM(radius_m) => {
950 covariance_from_horizontal_radius(PercentileRadius {
951 probability: 0.5,
952 radius_m,
953 approx_m: radius_m,
954 approx_valid: true,
955 })?
956 }
957 };
958 error_metrics::metrics_from_enu_covariance_m2(covariance)
959 .map_err(GeofenceError::ErrorMetrics)?;
960 Ok(covariance)
961}
962
963fn covariance_from_horizontal_radius(
964 radius: PercentileRadius,
965) -> Result<[[f64; 3]; 3], GeofenceError> {
966 validate_probability("radius.probability", radius.probability)?;
967 if !radius.radius_m.is_finite() || radius.radius_m < 0.0 {
968 return Err(invalid_input(
969 "radius.radius_m",
970 "must be finite non-negative",
971 ));
972 }
973 let sigma2 = if radius.radius_m == 0.0 {
974 0.0
975 } else {
976 let scale = -2.0 * (1.0 - radius.probability).ln();
977 radius.radius_m * radius.radius_m / scale
978 };
979 Ok([[sigma2, 0.0, 0.0], [0.0, sigma2, 0.0], [0.0, 0.0, 0.0]])
980}
981
982fn probability_boundary_normal_or_quadrature(
983 fence: &Fence,
984 position: Wgs84Geodetic,
985 boundary: BoundaryDistance,
986 covariance: [[f64; 3]; 3],
987) -> Result<f64, GeofenceError> {
988 let trace = covariance[0][0].max(0.0) + covariance[1][1].max(0.0);
989 let variance = projected_variance(covariance, boundary.normal_en).max(0.0);
990 if trace > 0.0 && variance <= trace * PSD_REL_TOL {
991 return probability_planar_quadrature(fence, position, boundary, covariance);
992 }
993 if variance == 0.0 {
994 if boundary.signed_distance_m == 0.0 {
995 return Ok(1.0);
996 }
997 return Ok(if boundary.signed_distance_m > 0.0 {
998 1.0
999 } else {
1000 0.0
1001 });
1002 }
1003 Ok(normal_cdf(boundary.signed_distance_m / variance.sqrt()).clamp(0.0, 1.0))
1004}
1005
1006fn probability_planar_quadrature(
1007 fence: &Fence,
1008 position: Wgs84Geodetic,
1009 boundary: BoundaryDistance,
1010 covariance: [[f64; 3]; 3],
1011) -> Result<f64, GeofenceError> {
1012 let vertices = local_vertices(fence, position)?;
1013 let eigen = horizontal_eigen(covariance)?;
1014 let scale = eigen.major_m2.abs().max(1.0);
1015 if eigen.major_m2 <= scale * PSD_REL_TOL {
1016 if boundary.signed_distance_m == 0.0 {
1017 return Ok(1.0);
1018 }
1019 return Ok(if boundary.signed_distance_m > 0.0 {
1020 1.0
1021 } else {
1022 0.0
1023 });
1024 }
1025 let origin_inside = boundary.signed_distance_m > 0.0;
1026 let origin_on_boundary = boundary.signed_distance_m == 0.0;
1027 if eigen.minor_m2 <= eigen.major_m2 * PSD_REL_TOL {
1028 return Ok(rank_one_probability(
1029 &vertices,
1030 origin_inside,
1031 origin_on_boundary,
1032 scale2(eigen.major_axis, eigen.major_m2.sqrt()),
1033 ));
1034 }
1035
1036 let major_sigma = eigen.major_m2.sqrt();
1037 let minor_sigma = eigen.minor_m2.sqrt();
1038 let integral = integrate_gl64(0.0, TWO_PI, |theta| {
1039 let unit = [theta.cos(), theta.sin()];
1040 let direction = add2(
1041 scale2(eigen.major_axis, major_sigma * unit[0]),
1042 scale2(eigen.minor_axis, minor_sigma * unit[1]),
1043 );
1044 radial_mass_along_direction(&vertices, origin_inside, origin_on_boundary, direction)
1045 });
1046 Ok((integral / TWO_PI).clamp(0.0, 1.0))
1047}
1048
1049fn local_vertices(fence: &Fence, position: Wgs84Geodetic) -> Result<Vec<[f64; 2]>, GeofenceError> {
1050 fence
1051 .vertices
1052 .iter()
1053 .map(|&vertex| project_from(position, vertex))
1054 .collect()
1055}
1056
1057fn projected_variance(covariance: [[f64; 3]; 3], normal_en: [f64; 2]) -> f64 {
1058 let e = normal_en[0];
1059 let n = normal_en[1];
1060 e * e * covariance[0][0] + 2.0 * e * n * covariance[0][1] + n * n * covariance[1][1]
1061}
1062
1063fn horizontal_eigen(covariance: [[f64; 3]; 3]) -> Result<HorizontalEigen, GeofenceError> {
1064 let a = covariance[0][0];
1065 let b = 0.5 * (covariance[0][1] + covariance[1][0]);
1066 let c = covariance[1][1];
1067 let center = 0.5 * (a + c);
1068 let half_delta = 0.5 * (a - c);
1069 let root = (half_delta * half_delta + b * b).sqrt();
1070 let major = center + root;
1071 let minor = center - root;
1072 let scale = major.abs().max(minor.abs()).max(1.0);
1073 if !major.is_finite() || !minor.is_finite() || minor < -scale * PSD_REL_TOL {
1074 return Err(GeofenceError::ErrorMetrics(
1075 error_metrics::ErrorMetricsError::NotPositiveSemidefinite,
1076 ));
1077 }
1078 let angle = if root == 0.0 {
1079 0.0
1080 } else {
1081 0.5 * (2.0 * b).atan2(a - c)
1082 };
1083 let major_axis = [angle.cos(), angle.sin()];
1084 let minor_axis = [-angle.sin(), angle.cos()];
1085 Ok(HorizontalEigen {
1086 major_m2: major.max(0.0),
1087 minor_m2: minor.max(0.0),
1088 major_axis,
1089 minor_axis,
1090 })
1091}
1092
1093fn radial_mass_along_direction(
1094 vertices: &[[f64; 2]],
1095 origin_inside: bool,
1096 origin_on_boundary: bool,
1097 direction: [f64; 2],
1098) -> f64 {
1099 if norm2(direction) == 0.0 {
1100 return if origin_on_boundary || origin_inside {
1101 1.0
1102 } else {
1103 0.0
1104 };
1105 }
1106 let mut hits = ray_intersections(vertices, direction);
1107 sort_dedup(&mut hits);
1108 let mut inside = if origin_on_boundary {
1109 point_in_polygon_planar(vertices, scale2(direction, RAY_PROBE))
1110 } else {
1111 origin_inside
1112 };
1113 let mut previous = 0.0_f64;
1114 let mut mass = 0.0_f64;
1115 for hit in hits {
1116 if inside {
1117 mass += (-0.5 * previous * previous).exp() - (-0.5 * hit * hit).exp();
1118 }
1119 inside = !inside;
1120 previous = hit;
1121 }
1122 if inside {
1123 mass += (-0.5 * previous * previous).exp();
1124 }
1125 mass.clamp(0.0, 1.0)
1126}
1127
1128fn ray_intersections(vertices: &[[f64; 2]], direction: [f64; 2]) -> Vec<f64> {
1129 let mut hits = Vec::new();
1130 for idx in 0..vertices.len() {
1131 let p = vertices[idx];
1132 let s = sub2(vertices[(idx + 1) % vertices.len()], p);
1133 let denom = cross2(direction, s);
1134 if denom.abs() <= RAY_EPS {
1135 continue;
1136 }
1137 let ray_t = cross2(p, s) / denom;
1138 let segment_t = cross2(p, direction) / denom;
1139 if ray_t > RAY_EPS && (-RAY_EPS..=1.0 + RAY_EPS).contains(&segment_t) {
1140 hits.push(ray_t);
1141 }
1142 }
1143 hits
1144}
1145
1146fn rank_one_probability(
1147 vertices: &[[f64; 2]],
1148 origin_inside: bool,
1149 origin_on_boundary: bool,
1150 direction: [f64; 2],
1151) -> f64 {
1152 if norm2(direction) == 0.0 {
1153 return if origin_on_boundary || origin_inside {
1154 1.0
1155 } else {
1156 0.0
1157 };
1158 }
1159 let mut hits = line_intersections(vertices, direction);
1160 sort_dedup(&mut hits);
1161 if hits.is_empty() {
1162 if origin_on_boundary {
1163 let plus_inside = point_in_polygon_planar(vertices, scale2(direction, RAY_PROBE));
1164 let minus_inside = point_in_polygon_planar(vertices, scale2(direction, -RAY_PROBE));
1165 return match (minus_inside, plus_inside) {
1166 (true, true) => 1.0,
1167 (false, false) => 0.0,
1168 _ => 0.5,
1169 };
1170 }
1171 return if origin_inside { 1.0 } else { 0.0 };
1172 }
1173 let mut probability = 0.0;
1174 for idx in 0..=hits.len() {
1175 let lo = if idx == 0 {
1176 f64::NEG_INFINITY
1177 } else {
1178 hits[idx - 1]
1179 };
1180 let hi = if idx == hits.len() {
1181 f64::INFINITY
1182 } else {
1183 hits[idx]
1184 };
1185 let probe = if lo.is_infinite() {
1186 hi - 1.0
1187 } else if hi.is_infinite() {
1188 lo + 1.0
1189 } else {
1190 0.5 * (lo + hi)
1191 };
1192 let point = scale2(direction, probe);
1193 if point_in_polygon_planar(vertices, point) {
1194 probability += normal_interval_probability(lo, hi);
1195 }
1196 }
1197 probability.clamp(0.0, 1.0)
1198}
1199
1200fn line_intersections(vertices: &[[f64; 2]], direction: [f64; 2]) -> Vec<f64> {
1201 let mut hits = Vec::new();
1202 for idx in 0..vertices.len() {
1203 let p = vertices[idx];
1204 let s = sub2(vertices[(idx + 1) % vertices.len()], p);
1205 let denom = cross2(direction, s);
1206 if denom.abs() <= RAY_EPS {
1207 continue;
1208 }
1209 let line_t = cross2(p, s) / denom;
1210 let segment_t = cross2(p, direction) / denom;
1211 if (-RAY_EPS..=1.0 + RAY_EPS).contains(&segment_t) {
1212 hits.push(line_t);
1213 }
1214 }
1215 hits
1216}
1217
1218fn normal_interval_probability(lo: f64, hi: f64) -> f64 {
1219 let cdf_hi = if hi.is_infinite() && hi.is_sign_positive() {
1220 1.0
1221 } else {
1222 normal_cdf(hi)
1223 };
1224 let cdf_lo = if lo.is_infinite() && lo.is_sign_negative() {
1225 0.0
1226 } else {
1227 normal_cdf(lo)
1228 };
1229 cdf_hi - cdf_lo
1230}
1231
1232fn integrate_gl64<F>(a: f64, b: f64, mut f: F) -> f64
1233where
1234 F: FnMut(f64) -> f64,
1235{
1236 let center = 0.5 * (a + b);
1237 let half = 0.5 * (b - a);
1238 let mut sum = 0.0;
1239 for idx in 0..GL64_POSITIVE_NODES.len() {
1240 let node = GL64_POSITIVE_NODES[idx];
1241 let weight = GL64_POSITIVE_WEIGHTS[idx];
1242 sum += weight * (f(center - half * node) + f(center + half * node));
1243 }
1244 half * sum
1245}
1246
1247fn normal_cdf(x: f64) -> f64 {
1248 0.5 * (1.0 + erf(x * core::f64::consts::FRAC_1_SQRT_2))
1249}
1250
1251fn sort_dedup(values: &mut Vec<f64>) {
1252 values.sort_by(f64::total_cmp);
1253 let mut deduped = Vec::with_capacity(values.len());
1254 for &value in values.iter() {
1255 if deduped
1256 .last()
1257 .is_none_or(|last: &f64| (value - *last).abs() > DEDUP_EPS)
1258 {
1259 deduped.push(value);
1260 }
1261 }
1262 *values = deduped;
1263}
1264
1265fn wrap_pi(angle: f64) -> f64 {
1266 let wrapped = (angle + core::f64::consts::PI).rem_euclid(TWO_PI) - core::f64::consts::PI;
1267 if wrapped == -core::f64::consts::PI {
1268 core::f64::consts::PI
1269 } else {
1270 wrapped
1271 }
1272}
1273
1274fn add2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
1275 [a[0] + b[0], a[1] + b[1]]
1276}
1277
1278fn sub2(a: [f64; 2], b: [f64; 2]) -> [f64; 2] {
1279 [a[0] - b[0], a[1] - b[1]]
1280}
1281
1282fn scale2(a: [f64; 2], scale: f64) -> [f64; 2] {
1283 [a[0] * scale, a[1] * scale]
1284}
1285
1286fn dot2(a: [f64; 2], b: [f64; 2]) -> f64 {
1287 a[0] * b[0] + a[1] * b[1]
1288}
1289
1290fn cross2(a: [f64; 2], b: [f64; 2]) -> f64 {
1291 a[0] * b[1] - a[1] * b[0]
1292}
1293
1294fn norm2(a: [f64; 2]) -> f64 {
1295 dot2(a, a).sqrt()
1296}