Source code for ferrosoft.apps.emiflow.services.visitor
"""Visitor implementations driven by :meth:`TransportChain.accept`.
Visitors here perform actions over an entire chain's structure
(elements, freight items, geo-points). :class:`DebuggerVisitor` dumps
every field to stdout for development; :class:`GeocodingRoutingVisitor`
populates missing coordinates and activity distances before emission
calculation.
"""
from django.utils.translation import gettext_lazy as _
from ferrosoft.apps.emiflow.models import (
TransportChain,
TransportChainElement,
FreightItem,
TransportChainVisitor,
TCEGeoPoint,
DistanceType,
OperationEpitype,
)
from ferrosoft.apps.emiflow.services.geographic import DistanceStrategyCalculator
from ferrosoft.apps.ferrobase.models import CoordinateDecimalField
from ferrosoft.apps.ferromaps.services.addresses import (
Geocoder,
AddressRouterService,
GreatCircleDistanceRouter,
AddressServiceException,
)
from ferrosoft.decimal import zero
[docs]
class DebuggerVisitor(TransportChainVisitor):
"""Visitor that prints every field of every visited model to stdout.
Intended for ad-hoc debugging only — production code should not
depend on it.
"""
# noinspection PyProtectedMember
@staticmethod
def _debug_fields(foo):
"""Print ``foo``'s class meta header followed by ``name: value`` per field."""
print("=== %s ===" % foo._meta)
for field in foo._meta.fields:
print("%s%s" % (field.name.ljust(50), getattr(foo, field.name)))
[docs]
def before_chain(self, chain: "TransportChain"):
self._debug_fields(chain)
[docs]
def on_freight_item(self, item: "FreightItem"):
self._debug_fields(item)
[docs]
def on_element(self, element: "TransportChainElement"):
self._debug_fields(element)
if hasattr(element, "geo_origin"):
self._debug_fields(element.geo_origin)
if hasattr(element, "geo_destination"):
self._debug_fields(element.geo_destination)
self._debug_fields(element.operation)
[docs]
class GeocodingRoutingError(RuntimeError):
"""Raised when geocoding or routing fails for a chain element."""
pass
[docs]
class GeocodingRoutingVisitor(TransportChainVisitor):
"""Fill in missing coordinates and activity distances on each TCE.
For every transport (non-hub) element with origin or destination
geo-points whose coordinates are still ``(0, 0)``, the address is
geocoded and the coordinates are persisted. Then, when the activity
distance has not been entered (or ``force_distance_calculation`` is
set), the configured :class:`DistanceStrategyCalculator` is used to
compute and store it. Run before booking to make sure emissions can
actually be calculated.
"""
def __init__(
self,
geocoder: Geocoder,
distance_calc: DistanceStrategyCalculator,
force_geocoding=False,
force_distance_calculation=False,
):
"""Wire up the geocoder, distance calculator, and force flags."""
self._force_distance_calculation = force_distance_calculation
self._force_geocoding = force_geocoding
self._geocoder = geocoder
self._distance_calc = distance_calc
[docs]
@classmethod
def get_instance(cls, **kwargs):
"""Build a visitor wired up with the default address-router-backed services."""
router = AddressRouterService.get_instance()
return cls(
router,
DistanceStrategyCalculator(GreatCircleDistanceRouter(), router),
**kwargs,
)
[docs]
def on_element(self, element: "TransportChainElement"):
"""Geocode geo-points and resolve activity distance for ``element``.
Hub elements are skipped — distance is irrelevant there. For
transport elements an origin and destination are required for
any distance type other than *actual distance*. Geocoding and
routing failures are re-raised as :class:`GeocodingRoutingError`
with a user-facing message.
"""
if OperationEpitype(element.epitype) == OperationEpitype.HUB:
# All the following code in this method pertains to activity distance,
# which doesn't play a role for HUB operations.
return
activity = element.activity_distance_parameters
if activity.origin is not None:
self._geocode_point(activity.origin)
elif activity.distance_type != DistanceType.ACTUAL_DISTANCE:
raise GeocodingRoutingError(
_("Origin is required for distance types other than actual distance")
)
if activity.destination is not None:
self._geocode_point(activity.destination)
elif activity.distance_type != DistanceType.ACTUAL_DISTANCE:
raise GeocodingRoutingError(
_(
"Destination is required for distance types other than actual distance"
)
)
if activity.distance == zero or self._force_distance_calculation:
try:
distance = self._distance_calc.distance(
activity, force_calculation=self._force_distance_calculation
)
element.activity_distance_value = distance.value
element.activity_distance_unit = distance.unit
element.save(
update_fields=[
"activity_distance_value",
"activity_distance_unit",
]
)
except AddressServiceException as e:
raise GeocodingRoutingError(_("Distance routing failed")) from e
def _geocode_point(self, point: TCEGeoPoint):
"""Geocode ``point``'s address and persist its coordinates.
No-op when the point already has non-zero coordinates and
``force_geocoding`` is ``False``.
"""
lat, lon = point.coordinates
if (lat == zero and lon == zero) or self._force_geocoding:
try:
location = point.address.geocode(self._geocoder)
point.longitude = CoordinateDecimalField.round(location.coords.lon)
point.latitude = CoordinateDecimalField.round(location.coords.lat)
point.save(
update_fields=[
"longitude",
"latitude",
]
)
except AddressServiceException as e:
raise GeocodingRoutingError(_("Geocoding failed")) from e