Skip to main content

opsis_core/
spatial.rs

1use serde::{Deserialize, Serialize};
2
3/// A geographic point in WGS-84 coordinates.
4#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
5pub struct GeoPoint {
6    /// Latitude in degrees (−90..90).
7    pub lat: f64,
8    /// Longitude in degrees (−180..180).
9    pub lon: f64,
10}
11
12impl GeoPoint {
13    /// Create a new `GeoPoint`.
14    pub fn new(lat: f64, lon: f64) -> Self {
15        Self { lat, lon }
16    }
17
18    /// Haversine distance to another point, in kilometres.
19    pub fn distance_km(&self, other: &Self) -> f64 {
20        const EARTH_RADIUS_KM: f64 = 6371.0;
21
22        let d_lat = (other.lat - self.lat).to_radians();
23        let d_lon = (other.lon - self.lon).to_radians();
24        let lat1 = self.lat.to_radians();
25        let lat2 = other.lat.to_radians();
26
27        let a = (d_lat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (d_lon / 2.0).sin().powi(2);
28        let c = 2.0 * a.sqrt().asin();
29        EARTH_RADIUS_KM * c
30    }
31}
32
33/// Axis-aligned bounding box defined by south-west and north-east corners.
34#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
35pub struct Bbox {
36    /// South-west corner (min lat, min lon).
37    pub sw: GeoPoint,
38    /// North-east corner (max lat, max lon).
39    pub ne: GeoPoint,
40}
41
42impl Bbox {
43    /// Create a new bounding box.
44    pub fn new(sw: GeoPoint, ne: GeoPoint) -> Self {
45        Self { sw, ne }
46    }
47
48    /// Returns `true` if the given point lies inside (or on the boundary of) this box.
49    pub fn contains(&self, point: &GeoPoint) -> bool {
50        point.lat >= self.sw.lat
51            && point.lat <= self.ne.lat
52            && point.lon >= self.sw.lon
53            && point.lon <= self.ne.lon
54    }
55}
56
57/// A geographic hotspot — a concentration of events around a center.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct GeoHotspot {
60    /// Center of the hotspot.
61    pub center: GeoPoint,
62    /// Radius in kilometres.
63    pub radius_km: f32,
64    /// Normalised intensity (0.0–1.0).
65    pub intensity: f32,
66    /// Number of events that contributed to this hotspot.
67    pub event_count: u32,
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn geopoint_distance_same_point_is_zero() {
76        let p = GeoPoint::new(4.711, -74.072);
77        assert!((p.distance_km(&p) - 0.0).abs() < 1e-9);
78    }
79
80    #[test]
81    fn geopoint_distance_bogota_to_cali() {
82        let bogota = GeoPoint::new(4.711, -74.072);
83        let cali = GeoPoint::new(3.4516, -76.532);
84        let km = bogota.distance_km(&cali);
85        // Roughly ~300 km
86        assert!(km > 280.0 && km < 320.0, "distance was {km} km");
87    }
88
89    #[test]
90    fn bbox_contains_inside_point() {
91        let bbox = Bbox::new(GeoPoint::new(0.0, 0.0), GeoPoint::new(10.0, 10.0));
92        assert!(bbox.contains(&GeoPoint::new(5.0, 5.0)));
93    }
94
95    #[test]
96    fn bbox_does_not_contain_outside_point() {
97        let bbox = Bbox::new(GeoPoint::new(0.0, 0.0), GeoPoint::new(10.0, 10.0));
98        assert!(!bbox.contains(&GeoPoint::new(11.0, 5.0)));
99    }
100}