Source code for timedatamodel.location

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from shapely.geometry import Polygon

_EARTH_RADIUS_KM = 6371.0
_KM_TO_MI = 0.621371


[docs] @dataclass(frozen=True, slots=True) class GeoLocation: latitude: float longitude: float def __post_init__(self) -> None: if not -90 <= self.latitude <= 90: raise ValueError(f"latitude must be between -90 and 90, got {self.latitude}") if not -180 <= self.longitude <= 180: raise ValueError( f"longitude must be between -180 and 180, got {self.longitude}" )
[docs] def distance_to(self, other: GeoLocation, unit: str = "km") -> float: """Haversine great-circle distance to *other*.""" lat1, lon1 = math.radians(self.latitude), math.radians(self.longitude) lat2, lon2 = math.radians(other.latitude), math.radians(other.longitude) dlat = lat2 - lat1 dlon = lon2 - lon1 a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) km = _EARTH_RADIUS_KM * c if unit == "km": return km if unit == "mi": return km * _KM_TO_MI raise ValueError(f"unsupported unit {unit!r}, use 'km' or 'mi'")
[docs] def bearing_to(self, other: GeoLocation) -> float: """Initial bearing in degrees [0, 360) from *self* to *other*.""" lat1, lon1 = math.radians(self.latitude), math.radians(self.longitude) lat2, lon2 = math.radians(other.latitude), math.radians(other.longitude) dlon = lon2 - lon1 x = math.sin(dlon) * math.cos(lat2) y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) return math.degrees(math.atan2(x, y)) % 360
[docs] def midpoint(self, other: GeoLocation) -> GeoLocation: """Geographic midpoint on the great circle.""" lat1, lon1 = math.radians(self.latitude), math.radians(self.longitude) lat2, lon2 = math.radians(other.latitude), math.radians(other.longitude) bx = math.cos(lat2) * math.cos(lon2 - lon1) by = math.cos(lat2) * math.sin(lon2 - lon1) lat3 = math.atan2( math.sin(lat1) + math.sin(lat2), math.sqrt((math.cos(lat1) + bx) ** 2 + by ** 2), ) lon3 = lon1 + math.atan2(by, math.cos(lat1) + bx) return GeoLocation( latitude=math.degrees(lat3), longitude=math.degrees(lon3), )
[docs] def offset(self, distance_km: float, bearing_deg: float) -> GeoLocation: """New point displaced by *distance_km* along *bearing_deg*.""" lat1 = math.radians(self.latitude) lon1 = math.radians(self.longitude) brng = math.radians(bearing_deg) d = distance_km / _EARTH_RADIUS_KM lat2 = math.asin( math.sin(lat1) * math.cos(d) + math.cos(lat1) * math.sin(d) * math.cos(brng) ) lon2 = lon1 + math.atan2( math.sin(brng) * math.sin(d) * math.cos(lat1), math.cos(d) - math.sin(lat1) * math.sin(lat2), ) return GeoLocation( latitude=math.degrees(lat2), longitude=math.degrees(lon2), )
[docs] def is_within(self, area: GeoArea) -> bool: """True if this point is inside *area*.""" try: from shapely.geometry import Point except ImportError: raise ImportError( "shapely is required for is_within(). " "Install it with: pip install timedatamodel[geo]" ) from None return area.polygon.contains(Point(self.longitude, self.latitude))
[docs] @dataclass(frozen=True, slots=True) class GeoArea: polygon: Polygon name: str | None = None @property def bounds(self) -> tuple[float, float, float, float]: return self.polygon.bounds @property def centroid(self) -> GeoLocation: c = self.polygon.centroid return GeoLocation(latitude=c.y, longitude=c.x)
[docs] @classmethod def from_coordinates(cls, coords: list[tuple[float, float]], name: str | None = None) -> GeoArea: """Create a GeoArea from a list of (lat, lon) coordinate pairs.""" try: from shapely.geometry import Polygon except ImportError: raise ImportError( "shapely is required for GeoArea. " "Install it with: pip install timedatamodel[geo]" ) from None # Shapely uses (x, y) = (lon, lat), so we swap xy_coords = [(lon, lat) for lat, lon in coords] return cls(polygon=Polygon(xy_coords), name=name)
[docs] def contains_point(self, location: GeoLocation) -> bool: """True if *location* is inside this area.""" from shapely.geometry import Point return self.polygon.contains(Point(location.longitude, location.latitude))
[docs] def contains_area(self, other: GeoArea) -> bool: """True if *other* is entirely inside this area.""" return self.polygon.contains(other.polygon)
[docs] def overlaps(self, other: GeoArea) -> bool: """True if this area and *other* share any space.""" return self.polygon.intersects(other.polygon)
[docs] def distance_to(self, other: GeoLocation | GeoArea) -> float: """Approximate distance in km (centroid-based Haversine). Returns 0.0 if the point is contained or the areas overlap. """ if isinstance(other, GeoLocation): if self.contains_point(other): return 0.0 return self.centroid.distance_to(other) if self.overlaps(other): return 0.0 return self.centroid.distance_to(other.centroid)
[docs] @classmethod def bounding_box( cls, center: GeoLocation, radius_km: float, name: str | None = None, ) -> GeoArea: """Create a rectangular area centered on *center* with half-side *radius_km*.""" n = center.offset(radius_km, 0) s = center.offset(radius_km, 180) e = center.offset(radius_km, 90) w = center.offset(radius_km, 270) coords = [ (n.latitude, w.longitude), (n.latitude, e.longitude), (s.latitude, e.longitude), (s.latitude, w.longitude), ] return cls.from_coordinates(coords, name=name)
Location = GeoLocation | GeoArea def _location_to_json(location: Location | None) -> dict | None: """Serialize a location object to a JSON-safe dict.""" if location is None: return None if isinstance(location, GeoLocation): return { "type": "GeoLocation", "latitude": location.latitude, "longitude": location.longitude, } if isinstance(location, GeoArea): coords = [ [lat, lon] for lon, lat in list(location.polygon.exterior.coords) ] return { "type": "GeoArea", "name": location.name, "coordinates": coords, } raise TypeError(f"unsupported location type: {type(location).__name__}") def _location_from_json(payload: dict | None) -> Location | None: """Deserialize a location dict produced by _location_to_json().""" if payload is None: return None if not isinstance(payload, dict): raise TypeError( f"location payload must be a dict or None, got {type(payload).__name__}" ) kind = payload.get("type") if kind == "GeoLocation": return GeoLocation( latitude=float(payload["latitude"]), longitude=float(payload["longitude"]), ) if kind == "GeoArea": raw_coords = payload.get("coordinates") if not isinstance(raw_coords, list) or len(raw_coords) < 3: raise ValueError( "GeoArea payload must contain at least 3 coordinate pairs" ) coords = [(float(lat), float(lon)) for lat, lon in raw_coords] name = payload.get("name") return GeoArea.from_coordinates(coords, name=name) raise ValueError(f"unknown location type: {kind!r}")