133 lines
4.6 KiB
Python
Executable File
133 lines
4.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Compare OSRM road distances against previously-recorded (Google) distances.
|
|
|
|
Routing distance feeds `riderkms` -> `ridercharges`, so a systematic difference
|
|
between the old and new routing backend changes what riders get paid. Run this
|
|
against a sample of completed deliveries BEFORE rolling out.
|
|
|
|
Input CSV (header required), one row per completed delivery:
|
|
|
|
origin_lat,origin_lng,dest_lat,dest_lng,recorded_km
|
|
11.0168,76.9558,10.9984,76.9776,4.6
|
|
11.0510,76.9910,11.0168,76.9558,6.1
|
|
|
|
Usage:
|
|
|
|
python3 tool/compare_route_distances.py deliveries.csv
|
|
python3 tool/compare_route_distances.py deliveries.csv \\
|
|
--osrm https://osrm.your-domain.in
|
|
"""
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
DEFAULT_OSRM = "https://routing.openstreetmap.de/routed-car"
|
|
USER_AGENT = "nearle-distance-check"
|
|
|
|
|
|
def _fetch(url, timeout):
|
|
"""GET a URL, falling back to curl when Python's SSL stack is too old.
|
|
|
|
Some systems ship a Python linked against an OpenSSL that fails the
|
|
handshake against modern servers; curl on the same box usually works.
|
|
"""
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return resp.read().decode()
|
|
except Exception: # noqa: BLE001
|
|
result = subprocess.run(
|
|
["curl", "-sS", "--max-time", str(timeout), "-A", USER_AGENT, url],
|
|
capture_output=True, text=True,
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(result.stderr.strip() or "curl failed") from None
|
|
return result.stdout
|
|
|
|
|
|
def osrm_km(base, o_lat, o_lng, d_lat, d_lng, timeout=25):
|
|
"""Road distance in km, or None. Note OSRM takes lon,lat."""
|
|
url = (
|
|
f"{base}/route/v1/driving/"
|
|
f"{o_lng},{o_lat};{d_lng},{d_lat}"
|
|
f"?{urllib.parse.urlencode({'overview': 'false'})}"
|
|
)
|
|
data = json.loads(_fetch(url, timeout))
|
|
if data.get("code") != "Ok" or not data.get("routes"):
|
|
return None
|
|
return data["routes"][0]["distance"] / 1000.0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("csv_path", help="CSV of completed deliveries")
|
|
ap.add_argument("--osrm", default=DEFAULT_OSRM,
|
|
help=f"OSRM base URL (default: {DEFAULT_OSRM})")
|
|
args = ap.parse_args()
|
|
|
|
deltas, failures = [], 0
|
|
|
|
print(f"{'recorded':>10} {'osrm':>10} {'delta':>10} {'pct':>8}")
|
|
print("-" * 42)
|
|
|
|
with open(args.csv_path, newline="") as fh:
|
|
for row in csv.DictReader(fh):
|
|
try:
|
|
recorded = float(row["recorded_km"])
|
|
km = osrm_km(
|
|
args.osrm,
|
|
float(row["origin_lat"]), float(row["origin_lng"]),
|
|
float(row["dest_lat"]), float(row["dest_lng"]),
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" ERROR: {exc}", file=sys.stderr)
|
|
failures += 1
|
|
continue
|
|
|
|
if km is None:
|
|
print(" no route found", file=sys.stderr)
|
|
failures += 1
|
|
continue
|
|
|
|
delta = km - recorded
|
|
pct = (delta / recorded * 100) if recorded else 0.0
|
|
deltas.append(pct)
|
|
print(f"{recorded:9.2f}k {km:9.2f}k {delta:+9.2f}k {pct:+7.1f}%")
|
|
|
|
if not deltas:
|
|
print("\nNo comparable rows.", file=sys.stderr)
|
|
return 1
|
|
|
|
mean = statistics.fmean(deltas)
|
|
median = statistics.median(deltas)
|
|
print("-" * 42)
|
|
print(f"rows compared : {len(deltas)} (failed: {failures})")
|
|
print(f"mean delta : {mean:+.1f}%")
|
|
print(f"median delta : {median:+.1f}%")
|
|
if len(deltas) > 1:
|
|
print(f"std dev : {statistics.stdev(deltas):.1f}%")
|
|
|
|
print()
|
|
if abs(median) < 3:
|
|
print("OK — no meaningful bias. Rider pay is unaffected.")
|
|
elif abs(median) < 8:
|
|
print("MILD BIAS — acceptable, but tell finance the average trip "
|
|
f"distance shifts {median:+.1f}%.")
|
|
else:
|
|
print(f"SIGNIFICANT BIAS ({median:+.1f}%) — investigate before rollout. "
|
|
"Usually means missing OSM roads in your service area; consider "
|
|
"contributing the missing roads to OpenStreetMap, which fixes it "
|
|
"permanently and for everyone.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|