From ff50a10ec19ebd5d94bd7fa44d227da702074b62 Mon Sep 17 00:00:00 2001
From: josidd <joseph.siddons@noc.ac.uk>
Date: Wed, 25 Sep 2024 13:46:28 +0100
Subject: [PATCH] feat: Comparison between quad/octtree and ellipse on surface
 of earth

---
 GeoSpatialTools/octtree.py  | 126 +++++++++++++++++++++++++++++++++++-
 GeoSpatialTools/quadtree.py | 102 ++++++++++++++++++++++++++++-
 2 files changed, 225 insertions(+), 3 deletions(-)

diff --git a/GeoSpatialTools/octtree.py b/GeoSpatialTools/octtree.py
index 05f0904..28cee15 100644
--- a/GeoSpatialTools/octtree.py
+++ b/GeoSpatialTools/octtree.py
@@ -1,5 +1,7 @@
 from datetime import datetime, timedelta
 from .distance_metrics import haversine
+from .distance_metrics import haversine, destination
+from math import degrees, sqrt
 
 
 class SpaceTimeRecord:
@@ -182,6 +184,11 @@ class SpaceTimeRectangle:
         -------
         bool : True if the point is <= dist + max(dist(centre, corners))
         """
+        if (
+            point.datetime - t_dist > self.datetime + self.dt / 2
+            or point.datetime + t_dist < self.datetime - self.dt / 2
+        ):
+            return False
         # QUESTION: Is this sufficient? Possibly it is overkill
         corner_dist = max(
             haversine(
@@ -207,8 +214,96 @@ class SpaceTimeRectangle:
         return (
             haversine(self.lon, self.lat, point.lon, point.lat)
             <= dist + corner_dist
-            and point.datetime - t_dist <= self.datetime + self.dt / 2
-            and point.datetime + t_dist >= self.datetime - self.dt / 2
+        )
+
+
+class SpaceTimeEllipse:
+    """A simple Ellipse Class for an ellipse on the surface of a sphere."""
+
+    def __init__(
+        self,
+        a: float,
+        b: float,
+        lon: float,
+        lat: float,
+        theta: float,
+        datetime: datetime,
+        dt: timedelta,
+    ) -> None:
+        self.a = a
+        self.b = b
+        self.lon = lon
+        self.lat = lat
+        self.datetime = datetime
+        self.dt = dt
+        # theta is anti-clockwise angle from horizontal in radians
+        self.theta = theta
+        # bearing is angle clockwise from north in degrees
+        self.bearing = (90 - degrees(self.theta)) % 360
+
+        a2 = self.a * self.a
+        b2 = self.b * self.b
+
+        self.c = sqrt(a2 - b2)
+        self.p1_lon, self.p1_lat = destination(
+            self.lon,
+            self.lat,
+            self.bearing,
+            self.c,
+        )
+        self.p2_lon, self.p2_lat = destination(
+            self.lon,
+            self.lat,
+            (180 - self.bearing) % 360,
+            self.c,
+        )
+
+    def contains(self, point: SpaceTimeRecord) -> bool:
+        """Test if a point is contained within the Ellipse"""
+        return (
+            (
+                haversine(self.p1_lon, self.p1_lat, point.lon, point.lat)
+                + haversine(self.p2_lon, self.p2_lat, point.lon, point.lat)
+            )
+            <= 2 * self.a
+            and point.datetime <= self.datetime + self.dt / 2
+            and point.datetime >= self.datetime - self.dt / 2
+        )
+
+    def nearby_rect(self, rect: SpaceTimeRectangle) -> bool:
+        """Test if a rectangle is near to the Ellipse"""
+        if (
+            rect.datetime - rect.dt / 2 > self.datetime + self.dt / 2
+            or rect.datetime + rect.dt / 2 < self.datetime - self.dt / 2
+        ):
+            return False
+        # TODO: Check corners, and 0 lat
+        corner_dist = max(
+            haversine(
+                rect.lon,
+                rect.lat,
+                rect.lon + rect.lon_range / 2,
+                rect.lat + rect.lat_range / 2,
+            ),
+            haversine(
+                rect.lon,
+                rect.lat,
+                rect.lon + rect.lon_range / 2,
+                rect.lat - rect.lat_range / 2,
+            ),
+        )
+        if (rect.lat + rect.lat_range / 2) * (
+            rect.lat - rect.lat_range / 2
+        ) < 0:
+            corner_dist = max(
+                corner_dist,
+                haversine(rect.lon, rect.lat, rect.lon + rect.lon_range / 2, 0),
+            )
+        return (
+            haversine(self.p1_lon, self.p1_lat, rect.lon, rect.lat)
+            <= corner_dist + self.a
+            or haversine(self.p2_lon, self.p2_lat, rect.lon, rect.lat)
+            <= corner_dist + self.a
         )
 
 
@@ -459,6 +554,33 @@ class OctTree:
 
         return points
 
+    def query_ellipse(
+        self,
+        ellipse: SpaceTimeEllipse,
+        points: SpaceTimeRecords | None = None,
+    ) -> SpaceTimeRecords:
+        """Get points that fall in an ellipse."""
+        if not points:
+            points = SpaceTimeRecords()
+        if not ellipse.nearby_rect(self.boundary):
+            return points
+
+        for point in self.points:
+            if ellipse.contains(point):
+                points.append(point)
+
+        if self.divided:
+            points = self.northwestfwd.query_ellipse(ellipse, points)
+            points = self.northeastfwd.query_ellipse(ellipse, points)
+            points = self.southwestfwd.query_ellipse(ellipse, points)
+            points = self.southeastfwd.query_ellipse(ellipse, points)
+            points = self.northwestback.query_ellipse(ellipse, points)
+            points = self.northeastback.query_ellipse(ellipse, points)
+            points = self.southwestback.query_ellipse(ellipse, points)
+            points = self.southeastback.query_ellipse(ellipse, points)
+
+        return points
+
     def nearby_points(
         self,
         point: SpaceTimeRecord,
diff --git a/GeoSpatialTools/quadtree.py b/GeoSpatialTools/quadtree.py
index 474dac9..b5f2088 100644
--- a/GeoSpatialTools/quadtree.py
+++ b/GeoSpatialTools/quadtree.py
@@ -4,7 +4,8 @@ for detecting nearby records for example
 """
 
 from datetime import datetime
-from .distance_metrics import haversine
+from .distance_metrics import haversine, destination
+from math import degrees, sqrt
 
 
 class Record:
@@ -144,6 +145,82 @@ class Rectangle:
         )
 
 
+class Ellipse:
+    """A simple Ellipse Class for an ellipse on the surface of a sphere."""
+
+    def __init__(
+        self,
+        a: float,
+        b: float,
+        lon: float,
+        lat: float,
+        theta: float,
+    ) -> None:
+        self.a = a
+        self.b = b
+        self.lon = lon
+        self.lat = lat
+        # theta is anti-clockwise angle from horizontal in radians
+        self.theta = theta
+        # bearing is angle clockwise from north in degrees
+        self.bearing = (90 - degrees(self.theta)) % 360
+
+        a2 = self.a * self.a
+        b2 = self.b * self.b
+
+        self.c = sqrt(a2 - b2)
+        self.p1_lon, self.p1_lat = destination(
+            self.lon,
+            self.lat,
+            self.bearing,
+            self.c,
+        )
+        self.p2_lon, self.p2_lat = destination(
+            self.lon,
+            self.lat,
+            (180 - self.bearing) % 360,
+            self.c,
+        )
+
+    def contains(self, point: Record) -> bool:
+        """Test if a point is contained within the Ellipse"""
+        return (
+            haversine(self.p1_lon, self.p1_lat, point.lon, point.lat)
+            + haversine(self.p2_lon, self.p2_lat, point.lon, point.lat)
+        ) <= 2 * self.a
+
+    def nearby_rect(self, rect: Rectangle) -> bool:
+        """Test if a rectangle is near to the Ellipse"""
+        # TODO: Check corners, and 0 lat
+        corner_dist = max(
+            haversine(
+                rect.lon,
+                rect.lat,
+                rect.lon + rect.lon_range / 2,
+                rect.lat + rect.lat_range / 2,
+            ),
+            haversine(
+                rect.lon,
+                rect.lat,
+                rect.lon + rect.lon_range / 2,
+                rect.lat - rect.lat_range / 2,
+            ),
+        )
+        if (rect.lat + rect.lat_range / 2) * (
+            rect.lat - rect.lat_range / 2
+        ) < 0:
+            corner_dist = max(
+                corner_dist,
+                haversine(rect.lon, rect.lat, rect.lon + rect.lon_range / 2, 0),
+            )
+        return (
+            haversine(self.p1_lon, self.p1_lat, rect.lon, rect.lat)
+            <= corner_dist + self.a
+            or haversine(self.p2_lon, self.p2_lat, rect.lon, rect.lat)
+            <= corner_dist + self.a
+        )
+
+
 class QuadTree:
     """
     A Simple QuadTree class for PyCOADS
@@ -288,6 +365,29 @@ class QuadTree:
 
         return points
 
+    def query_ellipse(
+        self,
+        ellipse: Ellipse,
+        points: list[Record] | None = None,
+    ) -> list[Record]:
+        """Get points that fall in an ellipse."""
+        if not points:
+            points = list()
+        if not ellipse.nearby_rect(self.boundary):
+            return points
+
+        for point in self.points:
+            if ellipse.contains(point):
+                points.append(point)
+
+        if self.divided:
+            points = self.northwest.query_ellipse(ellipse, points)
+            points = self.northeast.query_ellipse(ellipse, points)
+            points = self.southwest.query_ellipse(ellipse, points)
+            points = self.southeast.query_ellipse(ellipse, points)
+
+        return points
+
     def nearby_points(
         self,
         point: Record,
-- 
GitLab