""" 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