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)