"""
Address data types and geocoding/routing services.
.. deprecated::
This module is superseded by
:mod:`ferrosoft.apps.ferromaps.services.addresses`, which should be used
for all new code. The replacement offers several improvements:
* :class:`~ferrosoft.apps.ferromaps.services.addresses.Address` is a
mutable ``@dataclass`` instead of an immutable ``NamedTuple``.
* Coordinates and distances use :class:`~decimal.Decimal` instead of
``float``, avoiding floating-point rounding in emissions calculations.
* Geocoding and routing are split into separate abstract base classes
(:class:`~ferrosoft.apps.ferromaps.services.addresses.Geocoder` and
:class:`~ferrosoft.apps.ferromaps.services.addresses.DistanceRouter`)
that can be composed via
:class:`~ferrosoft.apps.ferromaps.services.addresses.SwitchingAddressRouter`.
* Optional Django-cache layer via
:class:`~ferrosoft.apps.ferromaps.services.addresses.CachingAddressRouter`.
* Database-backed fuzzy geocoding via
:class:`~ferrosoft.apps.ferromaps.services.addresses.FuzzyGeocoder`.
* The active router implementation is resolved dynamically from the
``FERROBASE_ADDRESS_ROUTER_CLASS`` setting instead of being hardwired to
Geoapify.
* :class:`~ferrosoft.apps.ferromaps.services.addresses.AddressRouterGeoapify`
falls back to city-level geocoding when a full-address lookup returns no
results, and raises typed exceptions
(:class:`~ferrosoft.apps.ferromaps.services.addresses.CoordinatesNotFound`,
:class:`~ferrosoft.apps.ferromaps.services.addresses.RoutingException`)
instead of bare :exc:`RuntimeError`.
"""
import string
from abc import ABC, abstractmethod
from typing import NamedTuple, List, Tuple
import requests
_config_key_address_router = "ADDRESS_ROUTER"
_server_key = "SERVER"
_config_key_map_config = "FERROBASE_MAP_CONFIG"
[docs]
class Address(NamedTuple):
"""Structured postal address as an immutable value object.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.Address`
instead. The replacement is a mutable ``@dataclass`` and is accepted
directly by the new service's ``geocode`` method.
All fields default to an empty string when constructed via
:meth:`from_dict`.
"""
first_name: str
last_name: str
salutation: str
company: str
additional_lines: str
street: str
house_no: str
city: str
postal_code: str
country: str
[docs]
@classmethod
def from_dict(cls, data: dict) -> "Address":
"""Construct an :class:`Address` from a dictionary, defaulting missing keys to ``""``.
Args:
data: Dictionary that may contain any subset of the field names as
keys.
Returns:
A fully populated :class:`Address` with empty strings for absent
fields.
"""
return cls(
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
salutation=data.get("salutation", ""),
company=data.get("company", ""),
additional_lines=data.get("additional_lines", ""),
street=data.get("street", ""),
house_no=data.get("house_no", ""),
city=data.get("city", ""),
postal_code=data.get("postal_code", ""),
country=data.get("country", ""),
)
[docs]
def geocode(self) -> "Location":
"""Geocode this address using the default :class:`AddressRouterService`.
.. deprecated::
Use :meth:`ferrosoft.apps.ferromaps.services.addresses.Address.geocode`
on the replacement ``Address`` class instead.
Returns:
The :class:`Location` resolved by
:class:`AddressRouterGeoapify`.
"""
service = AddressRouterService.default()
return service.geocode(self.format_lines(join=", "))
[docs]
class Coordinates(NamedTuple):
"""Geographic coordinates as a (latitude, longitude) pair using ``float``.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.Coordinates`
instead, which stores values as :class:`~decimal.Decimal` to preserve
precision in downstream calculations.
"""
lat: float
lon: float
[docs]
class Location(NamedTuple):
"""A resolved geographic location combining coordinates, a display name, and an address.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.Location`
instead.
"""
coords: Coordinates
name: str
address: Address
[docs]
class RouteLeg(NamedTuple):
"""A single leg of a route, defined by start and destination coordinates.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.RouteLeg`
instead.
"""
start: Coordinates
destination: Coordinates
[docs]
class Route(NamedTuple):
"""An ordered sequence of route legs.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.Route`
instead.
"""
legs: List[RouteLeg]
[docs]
class Distance(NamedTuple):
"""A measured distance with a unit string.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.Distance`
instead, which stores the value as :class:`~decimal.Decimal`.
"""
value: float
unit: str
[docs]
class AddressRouterService(ABC):
"""Abstract geocoding and routing service.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterService`
instead, which separates geocoding and routing into distinct abstract
base classes and supports pluggable implementations via
``FERROBASE_ADDRESS_ROUTER_CLASS``.
The :meth:`default` class method always returns an
:class:`AddressRouterGeoapify` instance.
"""
[docs]
@abstractmethod
def geocode(self, address: str) -> Location:
"""Geocode a free-form address string to a :class:`Location`.
Args:
address: Free-form address string to resolve.
Returns:
The resolved :class:`Location`.
"""
raise NotImplementedError
[docs]
@abstractmethod
def distance(self, route: Route) -> Distance | None:
"""Calculate the total distance of a route.
Args:
route: The :class:`Route` to measure.
Returns:
A :class:`Distance`, or ``None`` if the service could not
determine a distance.
"""
raise NotImplementedError
[docs]
@classmethod
def default(cls):
"""Return the default service instance (always :class:`AddressRouterGeoapify`).
.. deprecated::
Use :meth:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterService.get_instance`
instead, which selects the implementation from Django settings.
"""
return AddressRouterGeoapify.default()
[docs]
class AddressRouterFake(AddressRouterService):
"""In-memory stub implementation for use in tests.
Geocoding always returns a fixed dummy location. Distances are consumed
from an iterator supplied at construction time, returning ``None`` once
the iterator is exhausted.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterFake`
instead.
Args:
distances: Sequence of ``(value, unit)`` tuples to return from
successive :meth:`distance` calls.
"""
def __init__(self, distances: List[Tuple[int, str]]):
self._distances = iter(distances)
[docs]
def geocode(self, address: str) -> Location:
return Location(
coords=Coordinates(lat=42, lon=-100),
name=address,
address=Address(
first_name="",
last_name="",
salutation="",
company="",
additional_lines="",
street="Test Street",
house_no="12a",
city="Test City",
postal_code="10010",
country="XX",
),
)
[docs]
def distance(self, route: Route) -> Distance | None:
value, unit = next(self._distances, (None, None))
return None if value is None else Distance(value, unit)
[docs]
class AddressRouterGeoapify(AddressRouterService):
"""Geocoding and routing backed by the Geoapify REST API.
.. deprecated::
Use :class:`ferrosoft.apps.ferromaps.services.addresses.AddressRouterGeoapify`
instead. The replacement validates HTTP response status codes, falls
back to city-level geocoding on missing results, uses
:class:`~decimal.Decimal` for distances, and raises typed exceptions.
Configuration is read from ``settings.FERROBASE_MAP_CONFIG`` via
:class:`Config`.
"""
[docs]
class Config(NamedTuple):
"""Geoapify connection configuration.
Args:
base_url: Base URL of the Geoapify API (e.g. ``"https://api.geoapify.com"``).
api_key: Geoapify API key.
"""
base_url: string
api_key: string
[docs]
@classmethod
def from_django_settings(cls):
"""Read configuration from ``settings.FERROBASE_MAP_CONFIG``.
Returns:
A populated :class:`Config` instance.
"""
from django.conf import settings
config = getattr(settings, _config_key_map_config)
return cls(
base_url=config.get("base_url", ""),
api_key=config.get("api_key", ""),
)
def __init__(self, config: Config):
self.config = config
[docs]
@classmethod
def default(cls):
"""Return a new instance configured from Django settings."""
return cls(cls.Config.from_django_settings())
[docs]
def geocode(self, address: str) -> Location:
"""Resolve a free-form address string via the Geoapify geocoding API.
Args:
address: Free-form address string to geocode.
Returns:
A :class:`Location` built from the first Geoapify result.
Raises:
requests.HTTPError: On a non-2xx HTTP response.
RuntimeError: If the API response is malformed or returns no
results.
"""
response = requests.get(
f"{self.config.base_url}/geocode/search",
params={
"text": address,
"format": "json",
"apiKey": self.config.api_key,
},
)
response.raise_for_status()
api_result = response.json()
if not isinstance(api_result, dict):
raise RuntimeError("Invalid API response")
geocode_results = api_result.get("results", None)
if not isinstance(geocode_results, list) or len(geocode_results) == 0:
raise RuntimeError("No geocode results")
first_result = geocode_results[0]
return Location(
Coordinates(
lat=first_result.get("lat", 0),
lon=first_result.get("lon", 0),
),
first_result.get("formatted", address),
Address(
first_name="",
last_name="",
salutation="",
company="",
additional_lines="",
street=first_result.get("street", ""),
house_no=first_result.get("housenumber", ""),
city=first_result.get("city", ""),
postal_code=first_result.get("postcode"),
country=first_result.get("country_code", "").upper(),
),
)
[docs]
def distance(self, route: Route) -> Distance | None:
"""Calculate the road distance of a route via the Geoapify routing API.
Uses ``heavy_truck`` routing mode. Returns ``None`` if the API
response contains no feature results.
Args:
route: The :class:`Route` whose legs to measure.
Returns:
A :class:`Distance` in metres, or ``None`` if no route was found.
Raises:
requests.HTTPError: On a non-2xx HTTP response.
ValueError: If the API returns a non-metric distance unit.
"""
response = requests.get(
f"{self.config.base_url}/routing",
params={
"apiKey": self.config.api_key,
"waypoints": self._geoapify_waypoints(route),
"mode": "heavy_truck", # TODO this should be determined by emitter
"units": "metric",
"format": "geojson",
},
)
response.raise_for_status()
geo_result = response.json()
if isinstance(geo_result, dict):
features = geo_result.get("features", [])
if isinstance(features, list) and len(features) > 0:
first_feature = features[0]
properties = first_feature.get("properties", {})
unit = properties.get("distance_units", "")
if unit != "meters":
raise ValueError("Non metric response: {}".format(unit))
return Distance(
value=properties.get("distance", 0),
unit="m",
)
return None
@staticmethod
def _geoapify_waypoints(route: Route) -> str:
"""Encode route legs as a ``|``-separated Geoapify waypoint string.
Consecutive duplicate waypoints are collapsed to reduce API costs when
consecutive legs share a common endpoint.
Args:
route: The route whose legs to encode.
Returns:
A ``|``-separated string of ``"lat,lon"`` waypoints.
"""
waypoints = []
for i, leg in enumerate(route.legs):
start_waypoint = "%f,%f" % (leg.start.lat, leg.start.lon)
dest_waypoint = "%f,%f" % (leg.destination.lat, leg.destination.lon)
if len(waypoints) > 0:
# check if last waypoint is the same as start_waypoint, in which case
# do not add start_waypoint, avoiding duplicate subsequent waypoints.
# This reduces API costs.
last_waypoint = waypoints[-1]
if last_waypoint != start_waypoint:
waypoints.append(start_waypoint)
else:
waypoints.append(start_waypoint)
waypoints.append(dest_waypoint)
return "|".join(waypoints)