diff --git a/GeoSpatialTools/distance_metrics.py b/GeoSpatialTools/distance_metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..9d148c2f3af6369a85cd35de4e048f362356a0eb
--- /dev/null
+++ b/GeoSpatialTools/distance_metrics.py
@@ -0,0 +1,200 @@
+"""
+Functions for computing navigational information. Can be used to add
+navigational information to DataFrames.
+"""
+
+from math import acos, asin, atan2, cos, sin, degrees, radians, sqrt
+
+
+def gcd_slc(
+    lon0: float,
+    lat0: float,
+    lon1: float,
+    lat1: float,
+) -> float:
+    """
+    Compute great circle distance on earth surface between two locations.
+
+    Parameters
+    ----------
+    lon0 : float
+        Longitude of position 0
+    lat0 : float
+        Latitude of position 0
+    lon1 : float
+        Longitude of position 1
+    lat1 : float
+        Latitude of position 1
+
+    Returns
+    -------
+    dist : float
+        Great circle distance between position 0 and position 1.
+
+    """
+    if abs(lat0 - lat1) <= 1e-6 and abs(lon0 - lon1) <= 1e-6:
+        return 0
+
+    r_earth = 6371
+
+    # Convert to radians
+    lat0, lat1, lon0, lon1 = map(radians, [lat0, lat1, lon0, lon1])
+
+    return r_earth * acos(
+        sin(lat0) * sin(lat1) + cos(lat0) * cos(lat1) * cos(lon1 - lon0)
+    )
+
+
+def haversine(
+    lon0: float,
+    lat0: float,
+    lon1: float,
+    lat1: float,
+) -> float:
+    """
+    Compute Haversine distance between two points.
+
+    Parameters
+    ----------
+    lon0 : float
+        Longitude of position 0
+    lat0 : float
+        Latitude of position 0
+    lon1 : float
+        Longitude of position 1
+    lat1 : float
+        Latitude of position 1
+
+    Returns
+    -------
+    dist : float
+        Haversine distance between position 0 and position 1.
+
+    """
+    lat0, lat1, dlon, dlat = map(
+        radians, [lat0, lat1, lon1 - lon0, lat1 - lat0]
+    )
+    if abs(dlon) < 1e-6 and abs(dlat) < 1e-6:
+        return 0
+
+    r_earth = 6371
+
+    a = sin(dlat / 2) ** 2 + cos(lat0) * cos(lat1) * sin(dlon / 2) ** 2
+    c = 2 * asin(sqrt(a))
+    return c * r_earth
+
+
+def bearing(
+    lon0: float,
+    lat0: float,
+    lon1: float,
+    lat1: float,
+) -> float:
+    """
+    Compute the bearing of a track from (lon0, lat0) to (lon1, lat1).
+
+    Duplicated from geo-py
+
+    Parameters
+    ----------
+    lon0 : float,
+        Longitude of start point
+    lat0 : float,
+        Latitude of start point
+    lon1 : float,
+        Longitude of target point
+    lat1 : float,
+        Latitude of target point
+
+    Returns
+    -------
+    bearing : float
+        The bearing from point (lon0, lat0) to point (lon1, lat1) in degrees.
+    """
+    lon0, lat0, lon1, lat1 = map(radians, [lon0, lat0, lon1, lat1])
+
+    dlon = lon1 - lon0
+    numerator = sin(dlon) * cos(lat1)
+    denominator = cos(lat0) * sin(lat1) - (sin(lat0) * cos(lat1) * cos(dlon))
+
+    theta = atan2(numerator, denominator)
+    theta_deg = (degrees(theta) + 360) % 360
+    return theta_deg
+
+
+def destination(
+    lon: float, lat: float, bearing: float, distance: float
+) -> tuple[float, float]:
+    """
+    Compute destination of a great circle path.
+
+    Compute the destination of a track started from 'lon', 'lat', with
+    'bearing'. Distance is in units of km.
+
+    Duplicated from geo-py
+
+    Parameters
+    ----------
+    lon : float
+        Longitude of initial position
+    lat : float
+        Latitude of initial position
+    bearing : float
+        Direction of track
+    distance : float
+        Distance to travel
+
+    Returns
+    -------
+    destination : tuple[float, float]
+        Longitude and Latitude of final position
+    """
+    lon, lat = radians(lon), radians(lat)
+    radians_bearing = radians(bearing)
+    r_earth = 6371
+    delta = distance / r_earth
+
+    lat2 = asin(
+        sin(lat) * cos(delta) + cos(lat) * sin(delta) * cos(radians_bearing)
+    )
+    numerator = sin(radians_bearing) * sin(delta) * cos(lat)
+    denominator = cos(delta) - sin(lat) * sin(lat2)
+
+    lon2 = lon + atan2(numerator, denominator)
+
+    lon2_deg = (degrees(lon2) + 540) % 360 - 180
+    lat2_deg = degrees(lat2)
+
+    return lon2_deg, lat2_deg
+
+
+def midpoint(
+    lon0: float,
+    lat0: float,
+    lon1: float,
+    lat1: float,
+) -> tuple[float, float]:
+    """
+    Compute the midpoint of a great circle track
+
+    Parameters
+    ----------
+    lon0 : float
+        Longitude of position 0
+    lat0 : float
+        Latitude of position 0
+    lon1 : float
+        Longitude of position 1
+    lat1 : float
+        Latitude of position 1
+
+    Returns
+    -------
+    lon, lat
+        Positions of midpoint between position 0 and position 1
+
+    """
+    bear = bearing(lon0, lat0, lon1, lat1)
+    dist = haversine(lon0, lat0, lon1, lat1)
+
+    return destination(lon0, lat0, bear, dist / 2)
diff --git a/GeoSpatialTools/octtree.py b/GeoSpatialTools/octtree.py
new file mode 100644
index 0000000000000000000000000000000000000000..05f0904e388e6a82764f88716d265045fab674f3
--- /dev/null
+++ b/GeoSpatialTools/octtree.py
@@ -0,0 +1,542 @@
+from datetime import datetime, timedelta
+from .distance_metrics import haversine
+
+
+class SpaceTimeRecord:
+    """
+    ICOADS Record class.
+
+    This is a simple instance of an ICOARDS record, it requires position and
+    temporal data. It can optionally include a UID and extra data.
+
+    The temporal component was designed to use `datetime` values, however all
+    methods will work with numeric datetime information - for example a pentad,
+    timestamp, julian day, etc. Note that any uses within an OctTree and
+    SpaceTimeRectangle must also have timedelta values replaced with numeric
+    ranges in this case.
+
+    Equality is checked only on the required fields + UID if it is specified.
+
+    Parameters
+    ----------
+    lon : float
+        Horizontal coordinate (longitude).
+    lat : float
+        Vertical coordinate (latitude).
+    datetime : datetime
+        Datetime of the record. Can also be a numeric value such as pentad.
+        Comparisons between Records with datetime and Records with numeric
+        datetime will fail.
+    uid : str | None
+        Unique Identifier.
+    **data
+        Additional data passed to the SpaceTimeRecord for use by other functions
+        or classes.
+    """
+
+    def __init__(
+        self,
+        lon: float,
+        lat: float,
+        datetime: datetime,
+        uid: str | None = None,
+        **data,
+    ) -> None:
+        self.lon = lon
+        self.lat = lat
+        self.datetime = datetime
+        self.uid = uid
+        for var, val in data.items():
+            setattr(self, var, val)
+        return None
+
+    def __str__(self) -> str:
+        return f"Record(x = {self.lon}, y = {self.lat}, datetime = {self.datetime}, uid = {self.uid})"
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, SpaceTimeRecord)
+            and self.lon == other.lon
+            and self.lat == other.lat
+            and self.datetime == other.datetime
+            and (not (self.uid or other.uid) or self.uid == other.uid)
+        )
+
+
+class SpaceTimeRecords(list[SpaceTimeRecord]):
+    """List of SpaceTimeRecords"""
+
+
+class SpaceTimeRectangle:
+    """
+    A simple Space Time SpaceTimeRectangle class.
+
+    This constructs a simple Rectangle object.
+    The defining coordinates are the centres of the box, and the extents
+    are the full width, height, and time extent.
+
+    Whilst the rectangle is assumed to lie on the surface of Earth, this is
+    a projection as the rectangle is defined by a longitude/latitude range.
+
+    The temporal components are defined in the same way as the spatial
+    components, that is that the `datetime` component (t) is the "centre", and
+    the time extent (dt) is the full time range of the box.
+
+    Parameters
+    ----------
+    lon : float
+        Horizontal centre of the rectangle (longitude).
+    lat : float
+        Vertical centre of the rectangle (latitude).
+    datetime : datetime
+        Datetime centre of the rectangle.
+    w : float
+        Width of the rectangle (longitude range).
+    h : float
+        Height of the rectangle (latitude range).
+    dt : timedelta
+        Time extent of the rectangle.
+    """
+
+    def __init__(
+        self,
+        lon: float,
+        lat: float,
+        datetime: datetime,
+        lon_range: float,
+        lat_range: float,
+        dt: timedelta,
+    ) -> None:
+        self.lon = lon
+        self.lat = lat
+        self.lon_range = lon_range
+        self.lat_range = lat_range
+        self.datetime = datetime
+        self.dt = dt
+
+    def __str__(self) -> str:
+        return f"SpaceTimeRectangle(x = {self.lon}, y = {self.lat}, w = {self.lon_range}, h = {self.lat_range}, t = {self.datetime}, dt = {self.dt})"
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, SpaceTimeRectangle)
+            and self.lon == other.lon
+            and self.lat == other.lat
+            and self.lon_range == other.lon_range
+            and self.lat_range == other.lat_range
+            and self.datetime == other.datetime
+            and self.dt == other.dt
+        )
+
+    def contains(self, point: SpaceTimeRecord) -> bool:
+        """Test if a point is contained within the SpaceTimeRectangle"""
+        return (
+            point.lon <= self.lon + self.lon_range / 2
+            and point.lon >= self.lon - self.lon_range / 2
+            and point.lat <= self.lat + self.lat_range / 2
+            and point.lat >= self.lat - self.lat_range / 2
+            and point.datetime <= self.datetime + self.dt / 2
+            and point.datetime >= self.datetime - self.dt / 2
+        )
+
+    def intersects(self, other: object) -> bool:
+        """Test if another Rectangle object intersects this Rectangle"""
+        return isinstance(other, SpaceTimeRectangle) and not (
+            self.lon - self.lon_range / 2 > other.lon + other.lon_range / 2
+            or self.lon + self.lon_range / 2 < other.lon - other.lon_range / 2
+            or self.lat - self.lat_range / 2 > other.lat + other.lat_range / 2
+            or self.lat + self.lat_range / 2 < other.lat - other.lat_range / 2
+            or self.datetime - self.dt / 2 > other.datetime + other.dt / 2
+            or self.datetime + self.dt / 2 < other.datetime - other.dt / 2
+        )
+
+    def nearby(
+        self,
+        point: SpaceTimeRecord,
+        dist: float,
+        t_dist: timedelta,
+    ) -> bool:
+        """
+        Check if point is nearby the Rectangle
+
+        Determines if a SpaceTimeRecord that falls on the surface of Earth is
+        nearby to the rectangle in space and time. This calculation uses the
+        Haversine distance metric.
+
+        Distance from rectangle to point is challenging on the surface of a
+        sphere, this calculation will return false positives as a check based
+        on the distance from the centre of the rectangle to the corners, or
+        to its Eastern edge (if the rectangle crosses the equator) is used in
+        combination with the input distance.
+
+        The primary use-case of this method is for querying an OctTree for
+        nearby Records.
+
+        Parameters
+        ----------
+        point : SpaceTimeRecord
+        dist : float,
+        t_dist : timedelta
+
+        Returns
+        -------
+        bool : True if the point is <= dist + max(dist(centre, corners))
+        """
+        # QUESTION: Is this sufficient? Possibly it is overkill
+        corner_dist = max(
+            haversine(
+                self.lon,
+                self.lat,
+                self.lon + self.lon_range / 2,
+                self.lat + self.lat_range / 2,
+            ),
+            haversine(
+                self.lon,
+                self.lat,
+                self.lon + self.lon_range / 2,
+                self.lat - self.lat_range / 2,
+            ),
+        )
+        if (self.lat + self.lat_range / 2) * (
+            self.lat - self.lat_range / 2
+        ) < 0:
+            corner_dist = max(
+                corner_dist,
+                haversine(self.lon, self.lat, self.lon + self.lon_range / 2, 0),
+            )
+        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 OctTree:
+    """
+    A Simple OctTree class for PyCOADS.
+
+    Acts as a space-time OctTree on the surface of Earth, allowing for querying
+    nearby points faster than searching a full DataFrame. As SpaceTimeRecords
+    are added to the OctTree, the OctTree divides into 8 children as the
+    capacity is reached. Additional SpaceTimeRecords are then added to the
+    children where they fall within the child OctTree's boundary.
+
+    SpaceTimeRecords already part of the OctTree before divided are not
+    distributed to the children OctTrees.
+
+    Whilst the OctTree has a temporal component, and was designed to utilise
+    datetime / timedelta objects, numeric values and ranges can be used. This
+    usage must be consistent for the boundary and all SpaceTimeRecords that
+    are part of the OctTree. This allows for usage of pentad, timestamp,
+    Julian day, etc. as datetime values.
+
+    Parameters
+    ----------
+    boundary : SpaceTimeRectangle
+        The bounding SpaceTimeRectangle of the QuadTree
+    capacity : int
+        The capacity of each cell, if max_depth is set then a cell at the
+        maximum depth may contain more points than the capacity.
+    depth : int
+        The current depth of the cell. Initialises to zero if unset.
+    max_depth : int | None
+        The maximum depth of the QuadTree. If set, this can override the
+        capacity for cells at the maximum depth.
+    """
+
+    def __init__(
+        self,
+        boundary: SpaceTimeRectangle,
+        capacity: int = 5,
+        depth: int = 0,
+        max_depth: int | None = None,
+    ) -> None:
+        self.boundary = boundary
+        self.capacity = capacity
+        self.depth = depth
+        self.max_depth = max_depth
+        self.points = SpaceTimeRecords()
+        self.divided: bool = False
+        return None
+
+    def __str__(self) -> str:
+        indent = "    " * self.depth
+        out = f"{indent}OctTree:\n"
+        out += f"{indent}- boundary: {self.boundary}\n"
+        out += f"{indent}- capacity: {self.capacity}\n"
+        out += f"{indent}- depth: {self.depth}\n"
+        if self.max_depth:
+            out += f"{indent}- max_depth: {self.max_depth}\n"
+        if self.points:
+            out += f"{indent}- contents:\n"
+            out += f"{indent}- number of elements: {len(self.points)}\n"
+            for p in self.points:
+                out += f"{indent}  * {p}\n"
+        if self.divided:
+            out += f"{indent}- with children:\n"
+            out += f"{self.northwestback}"
+            out += f"{self.northeastback}"
+            out += f"{self.southwestback}"
+            out += f"{self.southeastback}"
+            out += f"{self.northwestfwd}"
+            out += f"{self.northeastfwd}"
+            out += f"{self.southwestfwd}"
+            out += f"{self.southeastfwd}"
+        return out
+
+    def divide(self):
+        """Divide the QuadTree"""
+        self.northwestfwd = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.datetime + self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.northeastfwd = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.datetime + self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southwestfwd = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.datetime + self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southeastfwd = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.datetime + self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.northwestback = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.datetime - self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.northeastback = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.datetime - self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southwestback = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.datetime - self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southeastback = OctTree(
+            SpaceTimeRectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.datetime - self.boundary.dt / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+                self.boundary.dt / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.divided = True
+
+    def _datetime_is_numeric(self) -> bool:
+        return not isinstance(self.boundary.datetime, datetime)
+
+    def insert(self, point: SpaceTimeRecord) -> bool:
+        """
+        Insert a SpaceTimeRecord into the QuadTree.
+
+        Note that the SpaceTimeRecord can have numeric datetime values if that
+        is consistent with the OctTree.
+        """
+        if not self.boundary.contains(point):
+            return False
+        elif self.max_depth and self.depth == self.max_depth:
+            self.points.append(point)
+            return True
+        elif len(self.points) < self.capacity:
+            self.points.append(point)
+            return True
+        else:
+            if not self.divided:
+                self.divide()
+            if self.northwestback.insert(point):
+                return True
+            elif self.northeastback.insert(point):
+                return True
+            elif self.southwestback.insert(point):
+                return True
+            elif self.southeastback.insert(point):
+                return True
+            elif self.northwestfwd.insert(point):
+                return True
+            elif self.northeastfwd.insert(point):
+                return True
+            elif self.southwestfwd.insert(point):
+                return True
+            elif self.southeastfwd.insert(point):
+                return True
+            return False
+
+    def query(
+        self,
+        rect: SpaceTimeRectangle,
+        points: SpaceTimeRecords | None = None,
+    ) -> SpaceTimeRecords:
+        """Get points that fall in a SpaceTimeRectangle"""
+        if not points:
+            points = SpaceTimeRecords()
+        if not self.boundary.intersects(rect):
+            return points
+
+        for point in self.points:
+            if rect.contains(point):
+                points.append(point)
+
+        if self.divided:
+            points = self.northwestfwd.query(rect, points)
+            points = self.northeastfwd.query(rect, points)
+            points = self.southwestfwd.query(rect, points)
+            points = self.southeastfwd.query(rect, points)
+            points = self.northwestback.query(rect, points)
+            points = self.northeastback.query(rect, points)
+            points = self.southwestback.query(rect, points)
+            points = self.southeastback.query(rect, points)
+
+        return points
+
+    def nearby_points(
+        self,
+        point: SpaceTimeRecord,
+        dist: float,
+        t_dist: timedelta,
+        points: SpaceTimeRecords | None = None,
+    ) -> SpaceTimeRecords:
+        """
+        Get all points that are nearby another point.
+
+        Query the OctTree to find all SpaceTimeRecords within the OctTree that
+        are nearby to the query SpaceTimeRecord. This search should be faster
+        than searching through all records, since only OctTree children whose
+        boundaries are close to the query SpaceTimeRecord are evaluated.
+
+        Parameters
+        ----------
+        point : SpaceTimeRecord
+            The query point.
+        dist : float
+            The distance for comparison. Note that Haversine distance is used
+            as the distance metric as the query SpaceTimeRecord and OctTree are
+            assumed to lie on the surface of Earth.
+        t_dist : timedelta
+            Max time gap between SpaceTimeRecords within the OctTree and the
+            query SpaceTimeRecord. Can be numeric if the OctTree boundaries,
+            SpaceTimeRecords, and query SpaceTimeRecord have numeric datetime
+            values and ranges.
+        points : SpaceTimeRecords | None
+            List of SpaceTimeRecords already found. Most use cases will be to
+            not set this value, since it's main use is for passing onto the
+            children OctTrees.
+
+        Returns
+        -------
+        SpaceTimeRecords : A list of SpaceTimeRecords whose distance to the
+        query SpaceTimeRecord is <= dist, and the datetimes of the
+        SpaceTimeRecords fall within the datetime range of the query
+        SpaceTimeRecord.
+        """
+        if not points:
+            points = SpaceTimeRecords()
+        if not self.boundary.nearby(point, dist, t_dist):
+            return points
+
+        for test_point in self.points:
+            if (
+                haversine(point.lon, point.lat, test_point.lon, test_point.lat)
+                <= dist
+                and test_point.datetime <= point.datetime + t_dist
+                and test_point.datetime >= point.datetime - t_dist
+            ):
+                points.append(test_point)
+
+        if self.divided:
+            points = self.northwestback.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.northeastback.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.southwestback.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.southeastback.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.northwestfwd.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.northeastfwd.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.southwestfwd.nearby_points(
+                point, dist, t_dist, points
+            )
+            points = self.southeastfwd.nearby_points(
+                point, dist, t_dist, points
+            )
+
+        return points
diff --git a/GeoSpatialTools/quadtree.py b/GeoSpatialTools/quadtree.py
new file mode 100644
index 0000000000000000000000000000000000000000..474dac9fae70c8a43220120d3a9a225d809531aa
--- /dev/null
+++ b/GeoSpatialTools/quadtree.py
@@ -0,0 +1,316 @@
+"""
+Constuctors for QuadTree classes that can decrease the number of comparisons
+for detecting nearby records for example
+"""
+
+from datetime import datetime
+from .distance_metrics import haversine
+
+
+class Record:
+    """
+    ICOADS Record class
+
+    Parameters
+    ----------
+    x : float
+        Horizontal coordinate
+    y : float
+        Vertical coordinate
+    datetime : datetime | None
+        Datetime of the record
+    uid : str | None
+        Unique Identifier
+    """
+
+    def __init__(
+        self,
+        lon: float,
+        lat: float,
+        datetime: datetime | None = None,
+        uid: str | None = None,
+        **data,
+    ) -> None:
+        self.lon = lon
+        self.lat = lat
+        self.datetime = datetime
+        self.uid = uid
+        for var, val in data.items():
+            setattr(self, var, val)
+        return None
+
+    def __str__(self) -> str:
+        return f"Record(x = {self.lon}, y = {self.lat}, datetime = {self.datetime}, uid = {self.uid})"
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, Record)
+            and self.lon == other.lon
+            and self.lat == other.lat
+            and self.datetime == other.datetime
+            and (not (self.uid or other.uid) or self.uid == other.uid)
+        )
+
+
+class Rectangle:
+    """
+    A simple Rectangle class
+
+    Parameters
+    ----------
+    x : float
+        Horizontal centre of the rectangle
+    y : float
+        Vertical centre of the rectangle
+    w : float
+        Width of the rectangle
+    h : float
+        Height of the rectangle
+    """
+
+    def __init__(
+        self,
+        lon: float,
+        lat: float,
+        lon_range: float,
+        lat_range: float,
+    ) -> None:
+        self.lon = lon
+        self.lat = lat
+        self.lon_range = lon_range
+        self.lat_range = lat_range
+
+    def __str__(self) -> str:
+        return f"Rectangle(x = {self.lon}, y = {self.lat}, w = {self.lon_range}, h = {self.lat_range})"
+
+    def __eq__(self, other: object) -> bool:
+        return (
+            isinstance(other, Rectangle)
+            and self.lon == other.lon
+            and self.lat == other.lat
+            and self.lon_range == other.lon_range
+            and self.lat_range == other.lat_range
+        )
+
+    def contains(self, point: Record) -> bool:
+        """Test if a point is contained within the Rectangle"""
+        return (
+            point.lon <= self.lon + self.lon_range / 2
+            and point.lon >= self.lon - self.lon_range / 2
+            and point.lat <= self.lat + self.lat_range / 2
+            and point.lat >= self.lat - self.lat_range / 2
+        )
+
+    def intersects(self, other: object) -> bool:
+        """Test if another Rectangle object intersects this Rectangle"""
+        return isinstance(other, Rectangle) and not (
+            self.lon - self.lon_range / 2 > other.lon + other.lon_range / 2
+            or self.lon + self.lon_range / 2 < other.lon - other.lon_range / 2
+            or self.lat - self.lat_range / 2 > other.lat + other.lat_range / 2
+            or self.lat + self.lat_range / 2 < other.lat - other.lat_range / 2
+        )
+
+    def nearby(
+        self,
+        point: Record,
+        dist: float,
+    ) -> bool:
+        """Check if point is nearby the Rectangle"""
+        # QUESTION: Is this sufficient? Possibly it is overkill
+        corner_dist = max(
+            haversine(
+                self.lon,
+                self.lat,
+                self.lon + self.lon_range / 2,
+                self.lat + self.lat_range / 2,
+            ),
+            haversine(
+                self.lon,
+                self.lat,
+                self.lon + self.lon_range / 2,
+                self.lat - self.lat_range / 2,
+            ),
+        )
+        if (self.lat + self.lat_range / 2) * (
+            self.lat - self.lat_range / 2
+        ) < 0:
+            corner_dist = max(
+                corner_dist,
+                haversine(self.lon, self.lat, self.lon + self.lon_range / 2, 0),
+            )
+        return (
+            haversine(self.lon, self.lat, point.lon, point.lat)
+            <= dist + corner_dist
+        )
+
+
+class QuadTree:
+    """
+    A Simple QuadTree class for PyCOADS
+
+    Parameters
+    ----------
+    boundary : Rectangle
+        The bounding Rectangle of the QuadTree
+    capacity : int
+        The capacity of each cell, if max_depth is set then a cell at the
+        maximum depth may contain more points than the capacity.
+    depth : int
+        The current depth of the cell. Initialises to zero if unset.
+    max_depth : int | None
+        The maximum depth of the QuadTree. If set, this can override the
+        capacity for cells at the maximum depth.
+    """
+
+    def __init__(
+        self,
+        boundary: Rectangle,
+        capacity: int = 5,
+        depth: int = 0,
+        max_depth: int | None = None,
+    ) -> None:
+        self.boundary = boundary
+        self.capacity = capacity
+        self.depth = depth
+        self.max_depth = max_depth
+        self.points: list[Record] = list()
+        self.divided: bool = False
+        return None
+
+    def __str__(self) -> str:
+        indent = "    " * self.depth
+        out = f"{indent}QuadTree:\n"
+        out += f"{indent}- boundary: {self.boundary}\n"
+        out += f"{indent}- capacity: {self.capacity}\n"
+        out += f"{indent}- depth: {self.depth}\n"
+        if self.max_depth:
+            out += f"{indent}- max_depth: {self.max_depth}\n"
+        out += f"{indent}- contents: {self.points}\n"
+        if self.divided:
+            out += f"{indent}- with children:\n"
+            out += f"{self.northwest}"
+            out += f"{self.northeast}"
+            out += f"{self.southwest}"
+            out += f"{self.southeast}"
+        return out
+
+    def divide(self):
+        """Divide the QuadTree"""
+        self.northwest = QuadTree(
+            Rectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.northeast = QuadTree(
+            Rectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat + self.boundary.lat_range / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southwest = QuadTree(
+            Rectangle(
+                self.boundary.lon - self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.southeast = QuadTree(
+            Rectangle(
+                self.boundary.lon + self.boundary.lon_range / 4,
+                self.boundary.lat - self.boundary.lat_range / 4,
+                self.boundary.lon_range / 2,
+                self.boundary.lat_range / 2,
+            ),
+            capacity=self.capacity,
+            depth=self.depth + 1,
+            max_depth=self.max_depth,
+        )
+        self.divided = True
+
+    def insert(self, point: Record) -> bool:
+        """Insert a point into the QuadTree"""
+        if not self.boundary.contains(point):
+            return False
+        elif self.max_depth and self.depth == self.max_depth:
+            self.points.append(point)
+            return True
+        elif len(self.points) < self.capacity:
+            self.points.append(point)
+            return True
+        else:
+            if not self.divided:
+                self.divide()
+            if self.northwest.insert(point):
+                return True
+            elif self.northeast.insert(point):
+                return True
+            elif self.southwest.insert(point):
+                return True
+            elif self.southeast.insert(point):
+                return True
+            return False
+
+    def query(
+        self,
+        rect: Rectangle,
+        points: list[Record] | None = None,
+    ) -> list[Record]:
+        """Get points that fall in a rectangle"""
+        if not points:
+            points = list()
+        if not self.boundary.intersects(rect):
+            return points
+
+        for point in self.points:
+            if rect.contains(point):
+                points.append(point)
+
+        if self.divided:
+            points = self.northwest.query(rect, points)
+            points = self.northeast.query(rect, points)
+            points = self.southwest.query(rect, points)
+            points = self.southeast.query(rect, points)
+
+        return points
+
+    def nearby_points(
+        self,
+        point: Record,
+        dist: float,
+        points: list[Record] | None = None,
+    ) -> list[Record]:
+        """Get all points that are nearby another point"""
+        if not points:
+            points = list()
+        if not self.boundary.nearby(point, dist):
+            return points
+
+        for test_point in self.points:
+            if (
+                haversine(point.lon, point.lat, test_point.lon, test_point.lat)
+                <= dist
+            ):
+                points.append(test_point)
+
+        if self.divided:
+            points = self.northwest.nearby_points(point, dist, points)
+            points = self.northeast.nearby_points(point, dist, points)
+            points = self.southwest.nearby_points(point, dist, points)
+            points = self.southeast.nearby_points(point, dist, points)
+
+        return points
diff --git a/test/test_octtree.py b/test/test_octtree.py
new file mode 100644
index 0000000000000000000000000000000000000000..0fd57d4231c06b8a68bf06a057a5832568ec653f
--- /dev/null
+++ b/test/test_octtree.py
@@ -0,0 +1,133 @@
+import unittest
+from datetime import datetime, timedelta
+
+from GeoSpatialTools.octtree import (
+    OctTree,
+    SpaceTimeRecord as Record,
+    SpaceTimeRectangle as Rectangle,
+)
+
+
+class TestRect(unittest.TestCase):
+    def test_contains(self):
+        d = datetime(2009, 1, 1, 0, 0)
+        dt = timedelta(days=14)
+        rect = Rectangle(10, 5, d, 20, 10, dt)
+        points: list[Record] = [
+            Record(10, 5, d),
+            Record(20, 10, d + timedelta(hours=4)),
+            Record(20, 10, datetime(2010, 4, 12, 13, 15)),
+            Record(0, 0, d - timedelta(days=6)),
+            Record(12.8, 2.1, d + timedelta(days=-1)),
+            Record(-2, -9.2, d),
+        ]
+        expected = [True, True, False, True, True, False]
+        res = list(map(rect.contains, points))
+        assert res == expected
+
+
+class TestOctTree(unittest.TestCase):
+    def test_divides(self):
+        d = datetime(2023, 3, 24, 12, 0)
+        dt = timedelta(days=1)
+
+        d1 = datetime(2023, 3, 24, 6, 0)
+        d2 = datetime(2023, 3, 24, 18, 0)
+        dt2 = timedelta(hours=12)
+
+        boundary = Rectangle(10, 4, d, 20, 8, dt)
+        otree = OctTree(boundary)
+        expected: list[Rectangle] = [
+            Rectangle(5, 6, d1, 10, 4, dt2),
+            Rectangle(15, 6, d1, 10, 4, dt2),
+            Rectangle(5, 2, d1, 10, 4, dt2),
+            Rectangle(15, 2, d1, 10, 4, dt2),
+            Rectangle(5, 6, d2, 10, 4, dt2),
+            Rectangle(15, 6, d2, 10, 4, dt2),
+            Rectangle(5, 2, d2, 10, 4, dt2),
+            Rectangle(15, 2, d2, 10, 4, dt2),
+        ]
+        otree.divide()
+        res = [
+            otree.northwestback.boundary,
+            otree.northeastback.boundary,
+            otree.southwestback.boundary,
+            otree.southeastback.boundary,
+            otree.northwestfwd.boundary,
+            otree.northeastfwd.boundary,
+            otree.southwestfwd.boundary,
+            otree.southeastfwd.boundary,
+        ]
+        assert res == expected
+
+    def test_insert(self):
+        d = datetime(2023, 3, 24, 12, 0)
+        dt = timedelta(days=10)
+        boundary = Rectangle(10, 4, d, 20, 8, dt)
+        otree = OctTree(boundary, capacity=3)
+        points: list[Record] = [
+            Record(10, 4, d, "main"),
+            Record(12, 1, d + timedelta(hours=3), "main2"),
+            Record(3, 7, d - timedelta(days=3), "main3"),
+            Record(13, 2, d + timedelta(hours=17), "southeastfwd"),
+            Record(3, 6, d - timedelta(days=1), "northwestback"),
+            Record(10, 4, d, "northwestback"),
+            Record(18, 2, d + timedelta(days=23), "not added"),
+            Record(11, 7, d + timedelta(hours=2), "northeastfwd"),
+        ]
+        for point in points:
+            otree.insert(point)
+        assert otree.divided
+        expected = [
+            points[:3],
+            points[4:6],
+            [],
+            [],
+            [],
+            [],
+            [points[-1]],
+            [],
+            [points[3]],
+        ]
+        res = [
+            otree.points,
+            otree.northwestback.points,
+            otree.northeastback.points,
+            otree.southwestback.points,
+            otree.southeastback.points,
+            otree.northwestfwd.points,
+            otree.northeastfwd.points,
+            otree.southwestfwd.points,
+            otree.southeastfwd.points,
+        ]
+        assert res == expected
+
+    def test_query(self):
+        d = datetime(2023, 3, 24, 12, 0)
+        dt = timedelta(days=10)
+        boundary = Rectangle(10, 4, d, 20, 8, dt)
+        otree = OctTree(boundary, capacity=3)
+        points: list[Record] = [
+            Record(10, 4, d, "main"),
+            Record(12, 1, d + timedelta(hours=3), "main2"),
+            Record(3, 7, d - timedelta(days=3), "main3"),
+            Record(13, 2, d + timedelta(hours=17), "southeastfwd"),
+            Record(3, 6, d - timedelta(days=1), "northwestback"),
+            Record(10, 4, d, "northwestback"),
+            Record(18, 2, d + timedelta(days=23), "not added"),
+            Record(11, 7, d + timedelta(hours=2), "northeastfwd"),
+        ]
+        for point in points:
+            otree.insert(point)
+        test_point = Record(11, 6, d + timedelta(hours=4))
+        expected = [Record(11, 7, d + timedelta(hours=2), "northeastfwd")]
+
+        res = otree.nearby_points(
+            test_point, dist=200, t_dist=timedelta(hours=5)
+        )
+
+        assert res == expected
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/test/test_quadtree.py b/test/test_quadtree.py
new file mode 100644
index 0000000000000000000000000000000000000000..3c913ff5c5c639dd7f7d52cec9b020b3bb132f58
--- /dev/null
+++ b/test/test_quadtree.py
@@ -0,0 +1,101 @@
+import unittest
+from GeoSpatialTools.quadtree import QuadTree, Record, Rectangle
+
+
+class TestRect(unittest.TestCase):
+    def test_contains(self):
+        rect = Rectangle(10, 5, 20, 10)
+        points: list[Record] = [
+            Record(10, 5),
+            Record(20, 10),
+            Record(0, 0),
+            Record(12.8, 2.1),
+            Record(-2, -9.2),
+        ]
+        expected = [True, True, True, True, False]
+        res = list(map(rect.contains, points))
+        assert res == expected
+
+    def test_intersection(self):
+        rect = Rectangle(10, 5, 20, 10)
+        test_rects: list[Rectangle] = [
+            Rectangle(10, 5, 18, 8),
+            Rectangle(25, 5, 9, 12),
+            Rectangle(15, 8, 12, 7),
+        ]
+        expected = [True, False, True]
+        res = list(map(rect.intersects, test_rects))
+        assert res == expected
+
+
+class TestQuadTree(unittest.TestCase):
+    def test_divides(self):
+        boundary = Rectangle(10, 4, 20, 8)
+        qtree = QuadTree(boundary)
+        expected: list[Rectangle] = [
+            Rectangle(5, 6, 10, 4),
+            Rectangle(15, 6, 10, 4),
+            Rectangle(5, 2, 10, 4),
+            Rectangle(15, 2, 10, 4),
+        ]
+        qtree.divide()
+        res = [
+            qtree.northwest.boundary,
+            qtree.northeast.boundary,
+            qtree.southwest.boundary,
+            qtree.southeast.boundary,
+        ]
+        assert res == expected
+
+    def test_insert(self):
+        boundary = Rectangle(10, 4, 20, 8)
+        qtree = QuadTree(boundary, capacity=3)
+        points: list[Record] = [
+            Record(10, 5),
+            Record(19, 1),
+            Record(0, 0),
+            Record(-2, -9.2),
+            Record(12.8, 2.1),
+        ]
+        expected = [
+            points[:3],
+            [],
+            [],
+            [],
+            [points[-1]],
+        ]
+        for point in points:
+            qtree.insert(point)
+        assert qtree.divided
+        res = [
+            qtree.points,
+            qtree.northwest.points,
+            qtree.northeast.points,
+            qtree.southwest.points,
+            qtree.southeast.points,
+        ]
+        assert res == expected
+
+    def test_query(self):
+        boundary = Rectangle(10, 4, 20, 8)
+        qtree = QuadTree(boundary, capacity=3)
+        points: list[Record] = [
+            Record(10, 5),
+            Record(19, 1),
+            Record(0, 0),
+            Record(-2, -9.2),
+            Record(12.8, 2.1),
+        ]
+        test_rect = Rectangle(12.5, 2.5, 1, 1)
+        expected = [Record(12.8, 2.1)]
+
+        for point in points:
+            qtree.insert(point)
+
+        res = qtree.query(test_rect)
+
+        assert res == expected
+
+
+if __name__ == "__main__":
+    unittest.main()