Skip to main content

sidereon_core/
geofence.rs

1//! Geodesic geofence containment with position uncertainty.
2//!
3//! Fences are polygons on WGS84. Vertices use geodetic latitude and longitude,
4//! while ellipsoidal height is ignored. Boundary distances are metres, positive
5//! inside the fence and negative outside it.
6
7use 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
25/// Maximum anchor-to-vertex, anchor-to-query, and edge length for the planar path.
26///
27/// When this 50 km bound is met, geodesic polar coordinates around the fence
28/// anchor are used as a local plane. Outside the bound every public distance and
29/// containment call uses the geodesic path.
30pub const PLANAR_FAST_PATH_MAX_RADIUS_M: f64 = 50_000.0;
31
32/// Boundary tolerance used when classifying a point on an edge, in metres.
33pub 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/// Error returned by geofence construction and evaluation.
108#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
109pub enum GeofenceError {
110    /// Fewer than three distinct polygon vertices were supplied.
111    #[error("geofence needs at least three vertices")]
112    TooFewVertices,
113    /// A fence input was malformed.
114    #[error("invalid geofence input {field}: {reason}")]
115    InvalidInput {
116        /// Name of the malformed field.
117        field: &'static str,
118        /// Stable validation reason.
119        reason: &'static str,
120    },
121    /// Geodesic direct or inverse evaluation failed.
122    #[error(transparent)]
123    Geodesic(#[from] GeodesicError),
124    /// ECEF covariance rotation failed.
125    #[error("covariance rotation failed")]
126    Dop(#[from] dop::DopError),
127    /// Covariance or percentile radius validation failed.
128    #[error("uncertainty validation failed")]
129    ErrorMetrics(error_metrics::ErrorMetricsError),
130}
131
132/// Geodesic polygon fence on WGS84.
133#[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    /// Construct a fence from WGS84 geodetic vertices.
143    ///
144    /// The polygon edge between adjacent vertices is the shortest WGS84
145    /// geodesic. A closing vertex equal to the first vertex is accepted and
146    /// removed. Height is ignored by containment and distance calculations.
147    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    /// Fence vertices in their open polygon form.
188    pub fn vertices(&self) -> &[Wgs84Geodetic] {
189        &self.vertices
190    }
191
192    /// Number of polygon edges.
193    pub fn edge_count(&self) -> usize {
194        self.edges.len()
195    }
196
197    /// Returns true when the small-region planar path can evaluate this point.
198    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    /// Boolean containment using geodesic polygon edges.
206    ///
207    /// Points within [`GEOFENCE_BOUNDARY_TOLERANCE_M`] of the boundary are
208    /// classified as contained.
209    pub fn contains(&self, position: Wgs84Geodetic) -> Result<bool, GeofenceError> {
210        Ok(self.distance_to_boundary(position)? >= -GEOFENCE_BOUNDARY_TOLERANCE_M)
211    }
212
213    /// Signed distance to the polygon boundary, in metres.
214    ///
215    /// Positive values are inside, negative values are outside, and values near
216    /// zero are on the boundary under [`GEOFENCE_BOUNDARY_TOLERANCE_M`].
217    pub fn distance_to_boundary(&self, position: Wgs84Geodetic) -> Result<f64, GeofenceError> {
218        Ok(self.boundary_distance_geodesic(position)?.signed_distance_m)
219    }
220
221    /// Fast signed boundary distance for small regions, in metres.
222    ///
223    /// Returns `None` when the fence or query point is outside
224    /// [`PLANAR_FAST_PATH_MAX_RADIUS_M`]. This is a local-plane approximation.
225    /// Use [`Self::distance_to_boundary`] when exact geodesic distance is
226    /// required.
227    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/// Position uncertainty accepted by probabilistic geofencing.
285#[derive(Debug, Clone, Copy, PartialEq)]
286pub enum PositionUncertainty {
287    /// Local east-north-up covariance in square metres.
288    EnuCovarianceM2([[f64; 3]; 3]),
289    /// ECEF covariance in square metres, rotated at the supplied position.
290    EcefCovarianceM2([[f64; 3]; 3]),
291    /// DOP position covariance carrying both ECEF and ENU forms.
292    PositionCovariance(PositionCovariance),
293    /// Horizontal percentile radius, converted to an isotropic EN covariance.
294    HorizontalRadius(PercentileRadius),
295    /// Circular error probable radius in metres.
296    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/// Probability integration method.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ProbabilityMethod {
314    /// Closed-form Gaussian probability from signed distance and normal variance.
315    BoundaryNormal,
316    /// Fixed angular quadrature over the local planarized fence.
317    PlanarQuadrature,
318}
319
320/// Options for [`containment_probability_with_options`].
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub struct ProbabilityOptions {
323    /// Probability integration method.
324    pub method: ProbabilityMethod,
325}
326
327impl Default for ProbabilityOptions {
328    fn default() -> Self {
329        Self {
330            method: ProbabilityMethod::BoundaryNormal,
331        }
332    }
333}
334
335/// Position estimate used by probabilistic crossing detection.
336#[derive(Debug, Clone, Copy, PartialEq)]
337pub struct GeofencePositionEstimate {
338    /// Estimated WGS84 geodetic position.
339    pub position: Wgs84Geodetic,
340    /// Position uncertainty associated with the estimate.
341    pub uncertainty: PositionUncertainty,
342}
343
344/// Hysteresis confidence for probabilistic crossing detection.
345#[derive(Debug, Clone, Copy, PartialEq)]
346pub struct ProbabilityHysteresis {
347    /// Required inside probability before emitting an entered event.
348    pub enter_confidence: f64,
349    /// Required outside probability before emitting a left event.
350    pub leave_confidence: f64,
351}
352
353impl ProbabilityHysteresis {
354    /// Construct hysteresis thresholds.
355    ///
356    /// Each confidence must be greater than 0.5 and less than 1.0. Values in
357    /// that range create a gap between the enter and leave conditions.
358    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/// Crossing event kind.
378#[derive(Debug, Clone, Copy, PartialEq, Eq)]
379pub enum CrossingKind {
380    /// The sequence entered the fence.
381    Entered,
382    /// The sequence left the fence.
383    Left,
384}
385
386/// Crossing event reported for a sequence sample.
387#[derive(Debug, Clone, Copy, PartialEq)]
388pub struct CrossingEvent {
389    /// Index of the sample that first satisfied the crossing condition.
390    pub sample_index: usize,
391    /// Direction of the crossing.
392    pub kind: CrossingKind,
393    /// Inside probability at the sample.
394    pub inside_probability: f64,
395}
396
397/// Boolean containment for one position and fence.
398pub fn containment(position: Wgs84Geodetic, fence: &Fence) -> Result<bool, GeofenceError> {
399    fence.contains(position)
400}
401
402/// Signed boundary distance for one position and fence, in metres.
403pub fn distance_to_boundary(position: Wgs84Geodetic, fence: &Fence) -> Result<f64, GeofenceError> {
404    fence.distance_to_boundary(position)
405}
406
407/// Containment probability from position uncertainty.
408///
409/// This uses [`ProbabilityMethod::BoundaryNormal`]. It evaluates the signed
410/// boundary distance and projects the horizontal covariance onto the nearest
411/// boundary normal. The resulting half-space probability is exact for a locally
412/// straight boundary and a Gaussian estimate in the local EN plane.
413pub 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
426/// Containment probability with an explicit integration method.
427pub 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
445/// Boolean crossing detection over a position sequence.
446pub 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
473/// Probabilistic crossing detection with probability hysteresis.
474pub 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
482/// Probabilistic crossing detection with explicit probability options.
483pub 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}