151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
"""
|
|
GPS location smoothing — rider-api
|
|
|
|
Smooths noisy rider GPS pings (typical error +-5-15m, worse on poor signal,
|
|
occasional bad-fix "jumps") using a per-rider exponential moving average
|
|
(EMA): each new reading is blended with the running estimate so a single bad
|
|
ping can't yank the rider's position, while the estimate still tracks real
|
|
movement.
|
|
|
|
This used to be implemented as a full Kalman filter (per-coordinate process/
|
|
measurement covariance, gain computed every update). For a "constant
|
|
position" state model with no velocity term — which is what this is, since
|
|
we're smoothing noisy pings, not tracking motion — the Kalman update reduces
|
|
mathematically to an EMA once the gain reaches steady state, which happens
|
|
within the first couple of updates. The EMA below is the same behavior with
|
|
one constant instead of two, and no covariance bookkeeping to explain to the
|
|
next person reading this file.
|
|
|
|
Only two things are actually used elsewhere in the app:
|
|
smooth_rider_locations(riders) — per-rider EMA, stateful across calls
|
|
smooth_order_coordinates(orders) — validates/normalises delivery coords
|
|
(NOT smoothed — see its docstring)
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Dict, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Smoothing factor: how much weight a new GPS reading gets against the
|
|
# running estimate. Lower = smoother/slower to react, higher = trusts each
|
|
# new ping more. 0.1 matches the steady-state behavior of the Kalman filter
|
|
# this replaced (process_noise=1e-4, measurement_noise=0.01).
|
|
_ALPHA = 0.1
|
|
_STALE_SECONDS = 1800.0 # reset a rider's running estimate after 30 min silence
|
|
|
|
|
|
def _is_valid_coord(lat: float, lon: float) -> bool:
|
|
try:
|
|
lat, lon = float(lat), float(lon)
|
|
return (
|
|
-90.0 <= lat <= 90.0
|
|
and -180.0 <= lon <= 180.0
|
|
and not (lat == 0.0 and lon == 0.0)
|
|
)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
class _RiderEstimate:
|
|
"""Running EMA estimate of one rider's position."""
|
|
|
|
def __init__(self):
|
|
self.lat: Optional[float] = None
|
|
self.lon: Optional[float] = None
|
|
self.last_updated: float = time.time()
|
|
|
|
def update(self, lat: float, lon: float) -> Tuple[float, float]:
|
|
if not _is_valid_coord(lat, lon):
|
|
return (self.lat, self.lon) if self.lat is not None else (lat, lon)
|
|
|
|
if time.time() - self.last_updated > _STALE_SECONDS:
|
|
self.lat = self.lon = None # stale — start fresh
|
|
|
|
if self.lat is None:
|
|
self.lat, self.lon = lat, lon
|
|
else:
|
|
self.lat += _ALPHA * (lat - self.lat)
|
|
self.lon += _ALPHA * (lon - self.lon)
|
|
|
|
self.last_updated = time.time()
|
|
return self.lat, self.lon
|
|
|
|
|
|
_rider_estimates: Dict[str, _RiderEstimate] = {}
|
|
|
|
|
|
def smooth_rider_locations(riders: list) -> list:
|
|
"""
|
|
Apply EMA smoothing to a list of rider dicts in-place, keyed by rider id
|
|
(history preserved across calls via a process-level registry).
|
|
|
|
Reads/writes: latitude, longitude (and currentlat/currentlong if present).
|
|
Adds: _location_smoothed = True on each processed rider.
|
|
"""
|
|
for rider in riders:
|
|
try:
|
|
rider_id = str(
|
|
rider.get("userid") or rider.get("riderid") or
|
|
rider.get("id") or "unknown"
|
|
)
|
|
raw_lat = float(rider.get("latitude") or rider.get("currentlat") or 0)
|
|
raw_lon = float(rider.get("longitude") or rider.get("currentlong") or 0)
|
|
if raw_lat == 0.0 and raw_lon == 0.0:
|
|
continue
|
|
|
|
estimate = _rider_estimates.setdefault(rider_id, _RiderEstimate())
|
|
smooth_lat, smooth_lon = estimate.update(raw_lat, raw_lon)
|
|
|
|
# Cast back to string for Go compatibility
|
|
s_lat, s_lon = str(round(smooth_lat, 8)), str(round(smooth_lon, 8))
|
|
rider["latitude"] = s_lat
|
|
rider["longitude"] = s_lon
|
|
if "currentlat" in rider:
|
|
rider["currentlat"] = s_lat
|
|
if "currentlong" in rider:
|
|
rider["currentlong"] = s_lon
|
|
rider["_location_smoothed"] = True
|
|
except Exception as e:
|
|
logger.debug(f"Rider location smoothing skipped: {e}")
|
|
return riders
|
|
|
|
|
|
def smooth_order_coordinates(orders: list) -> list:
|
|
"""
|
|
Validate and lightly normalise delivery coordinates in a list of order dicts.
|
|
|
|
DESIGN NOTE — why these are NOT smoothed:
|
|
Smoothing blends successive measurements from the same source over time.
|
|
Delivery coordinates are a single static point (one measurement) — there
|
|
is nothing to blend. Per-customer GPS accuracy is handled upstream by the
|
|
FAISS coordinate store (verified historical rider-confirmed delivery
|
|
points). This function only normalises the coordinate fields to floats
|
|
so downstream code never sees raw strings or None values.
|
|
|
|
Modifies orders in-place. Returns the same list.
|
|
"""
|
|
for order in orders:
|
|
try:
|
|
dlat_raw = order.get("deliverylat") or order.get("droplat")
|
|
dlon_raw = order.get("deliverylong") or order.get("droplon")
|
|
if dlat_raw is None or dlon_raw is None:
|
|
continue
|
|
dlat = float(dlat_raw)
|
|
dlon = float(dlon_raw)
|
|
if not _is_valid_coord(dlat, dlon):
|
|
continue
|
|
# Normalise to string (Go service expects string coordinates)
|
|
s_lat = str(round(dlat, 8))
|
|
s_lon = str(round(dlon, 8))
|
|
order["deliverylat"] = s_lat
|
|
order["deliverylong"] = s_lon
|
|
if "droplat" in order:
|
|
order["droplat"] = s_lat
|
|
if "droplon" in order:
|
|
order["droplon"] = s_lon
|
|
except Exception as e:
|
|
logger.debug(f"Coordinate normalisation skipped: {e}")
|
|
return orders
|