Files
routesapi/app/services/routing/gps_smoother.py
2026-07-06 15:15:51 +05:30

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