Source code for ferrosoft.apps.emiflow.services.geographic
"""Strategy that resolves activity distance from a TCE's distance type.
For *actual* distance the user-entered value is treated as authoritative.
For *great-circle* (GCD) and *shortest-feasible* (SFD) distances the
configured router computes the distance between origin and destination
geo-points. Results are normalised to kilometres.
"""
from decimal import Decimal
from django.utils.translation import gettext as _
from ferrosoft.apps.emiflow.models import ActivityDistanceParameters, DistanceType
from ferrosoft.apps.ferrobase.models.decimal import DecimalWithUnit
from ferrosoft.apps.ferromaps.services.addresses import (
DistanceRouter,
Route,
RouteLeg,
Coordinates,
)
from ferrosoft.decimal import zero
[docs]
class GeographicError(Exception):
"""Raised when distance resolution cannot satisfy the requested strategy."""
pass
[docs]
class DistanceStrategyCalculator:
"""Resolve a TCE's activity distance according to its :class:`DistanceType`."""
def __init__(self, gcd_router: DistanceRouter, sfd_router: DistanceRouter):
"""Wire up the great-circle and shortest-feasible distance routers."""
self._gcd_router = gcd_router
self._sfd_router = sfd_router
[docs]
def distance(
self, activity: ActivityDistanceParameters, force_calculation=False
) -> DecimalWithUnit | None:
"""Return the activity distance for ``activity``.
Args:
activity: Bundles distance type, the already-entered
distance, the DAF, and origin/destination geo-points.
force_calculation: When ``True``, ignore any pre-existing
``activity.distance`` value and re-route from
origin/destination.
Returns:
DecimalWithUnit: The resolved distance, or ``None`` for the
*actual distance* strategy (the caller should keep the
user-entered value).
Raises:
GeographicError: When the inputs are inconsistent with the
strategy (zero actual distance, missing origin or
destination, unrouted route, or unknown distance type).
"""
match activity.distance_type:
case DistanceType.ACTUAL_DISTANCE:
if activity.distance == zero:
raise GeographicError(
_(
"Activity distance must be greater than zero for %(distance_type)s"
)
% {"distance_type": DistanceType.ACTUAL_DISTANCE.label}
)
return None
case DistanceType.GREAT_CIRCLE:
return self._distance_via_router(
activity, self._gcd_router, force_calculation=force_calculation
)
case DistanceType.SHORTEST_FEASIBLE:
return self._distance_via_router(
activity, self._sfd_router, force_calculation=force_calculation
)
case _:
raise GeographicError(
"Unsupported distance type: %s", activity.distance_type
)
@staticmethod
def _distance_via_router(
activity: ActivityDistanceParameters,
router: DistanceRouter,
force_calculation=False,
) -> DecimalWithUnit | None:
"""Resolve distance via ``router``, preferring the cached value when present.
Returns the existing distance when one is set and
``force_calculation`` is ``False``; otherwise builds a single-leg
route between origin and destination, asks the router for the
distance, and converts metres to kilometres.
"""
if activity.distance != zero and not force_calculation:
return activity.distance
if activity.origin is None or activity.destination is None:
raise GeographicError("Origin and destination are required")
distance = router.distance(
Route(
[
RouteLeg(
start=Coordinates(*activity.origin.coordinates),
destination=Coordinates(*activity.destination.coordinates),
)
]
)
)
if distance is None:
raise GeographicError("Routing between origin and destination failed")
if distance.unit == "m":
return DecimalWithUnit.create(distance.value / Decimal(1000), "km")
return DecimalWithUnit.create(distance.value, distance.unit)