#!/usr/bin/env python3 """Stamp randomized coordinates on this-month's fuel_log rows that have zero/null lat/lng, plus bump each affected vehicle's last-known position. WHY this exists: Buildsite's CSV export doesn't carry GPS, so every imported fuel_log row lands at (0, 0). When the dashboard map plots them all, every vehicle stacks on the equator off the coast of Africa — useless for visual review of which truck is where. Until the scan-app (or a future Buildsite GPS field) gives us real positions, we seed each zero-coord fill with a uniform-area random point inside a configurable radius of the site center so the map shows a believable spread instead of a single stack. WHAT it does: For each (site, month-window) tuple: 1. List every fuel_log at the site inside the window. 2. For each row with lat==0 and lng==0, PATCH a randomized point. 3. For each affected vehicle, find its most recent fill (post-update) and copy that row's coords onto the vehicle's last-known fields. USAGE: python3 scripts/randomize-site-coords.py [--radius-km 1.5] \\ [--sites 8qyqytyl62rysu1 u6n5fye4czinw6o] \\ [--month 2026-09] DEFAULTS: radius_km = 1.5 (uniform-area within a circle of this radius) sites = The Hills ext 12 + Cedar Gate month = current month in Africa/Johannesburg IDEMPOTENT: Rows whose coords are already non-zero are skipped. Pass `--force` to overwrite existing coords (e.g. when tightening the radius). """ import argparse import json import math import random import sys import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone import pb_env from pb_env import http, superuser_token PB = pb_env.PB TOKEN = superuser_token() def list_sites() -> dict[str, dict]: code, body = http("GET", "/api/collections/sites/records?perPage=500", token=TOKEN) if code != 200: sys.exit(f"list sites failed: {code} {body}") return {s["id"]: s for s in body.get("items", [])} def list_fuel_logs_for_site_month(site_id: str, month_yyyymm: str) -> list[dict]: """Return all fuel_logs at site in the given month, paginated. PB's filter syntax doesn't reliably combine `~and~` with date comparisons, so we fetch everything for the site and filter by month client-side. For our largest site (1201 rows) this is one round-trip. """ year, month = month_yyyymm.split("-") next_year = int(year) + (1 if month == "12" else 0) next_month_num = 1 if month == "12" else int(month) + 1 items: list[dict] = [] page = 1 from urllib.parse import quote while True: filt = quote(f"site='{site_id}'") url = ( f"{PB}/api/collections/fuel_logs/records" f"?filter={filt}&fields=id,date_logged,vehicle,latitude,longitude,location_accuracy_m" f"&perPage=500&page={page}" ) req = urllib.request.Request(url, headers={"Authorization": TOKEN}) body = json.loads(urllib.request.urlopen(req).read().decode()) page_items = body.get("items", []) items.extend(page_items) if len(page_items) < 500: break page += 1 in_month: list[dict] = [] for r in items: d = (r.get("date_logged") or "")[:10] if d.startswith(f"{year}-{int(month):02d}-"): in_month.append(r) return in_month def random_offset_deg(radius_km: float, lat: float) -> tuple[float, float]: """Uniform-area random offset (dlat, dlng) inside a circle of radius_km. Uses sqrt(uniform) on the radius so points are equally likely per unit area, not clumped at the center. Longitude is scaled by cos(lat) so the circle is round on the ground, not stretched in the east-west direction. """ theta = random.uniform(0, 2 * math.pi) # 1 degree latitude ~ 110.574 km everywhere km_per_deg_lat = 110.574 km_per_deg_lng = 111.320 * math.cos(math.radians(lat)) r = radius_km * math.sqrt(random.random()) dlat = (r * math.sin(theta)) / km_per_deg_lat dlng = (r * math.cos(theta)) / km_per_deg_lng return dlat, dlng def patch_record(collection: str, rid: str, body: dict) -> tuple[str, int]: req = urllib.request.Request( f"{PB}/api/collections/{collection}/records/{rid}", data=json.dumps(body).encode(), method="PATCH", headers={"Authorization": TOKEN, "Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req) as r: return rid, r.status except urllib.error.HTTPError as e: return rid, e.code def run(site_id: str, month_yyyymm: str, radius_km: float, force: bool = False) -> tuple[int, int, dict]: sites = list_sites() site = sites.get(site_id) if not site: sys.exit(f"site {site_id!r} not found in PB") lat0 = site.get("latitude") lng0 = site.get("longitude") if lat0 is None or lng0 is None: sys.exit(f"site {site_id!r} ({site.get('name')}) has no lat/lng in PB") print(f"\n=== site {site_id} ({site.get('name')}) @ ({lat0}, {lng0}) ===") rows = list_fuel_logs_for_site_month(site_id, month_yyyymm) print(f" fetched {len(rows)} fuel_logs for {month_yyyymm}") if force: zero_rows = list(rows) print(f" --force: re-stamping all {len(zero_rows)} rows") else: zero_rows = [ r for r in rows if (r.get("latitude") in (None, 0)) and (r.get("longitude") in (None, 0)) ] print(f" zero-coord rows to stamp: {len(zero_rows)}") # Even if there are no rows to stamp, we still want to (re)derive # vehicle last-known positions from already-stamped rows in this # month — e.g. on a re-run after only the vehicle fields drift. stamped_rows = list(zero_rows) if not stamped_rows: stamped_rows = [ r for r in rows if (r.get("latitude") not in (None, 0)) and (r.get("longitude") not in (None, 0)) ] if not stamped_rows: return 0, 0, {} # Randomize each row + PATCH in parallel successes = 0 errors = [] radius_m = int(radius_km * 1000) def stamp(r): dlat, dlng = random_offset_deg(radius_km, lat0) new_lat = round(lat0 + dlat, 6) new_lng = round(lng0 + dlng, 6) body = { "latitude": new_lat, "longitude": new_lng, "location_accuracy_m": radius_m, } return r, patch_record("fuel_logs", r["id"], body) with ThreadPoolExecutor(max_workers=16) as ex: futures = {ex.submit(stamp, r): r for r in zero_rows} latest_per_vehicle: dict[str, dict] = {} for fut in as_completed(futures): r, (rid, code) = fut.result() if code == 200: successes += 1 dlat, dlng = random_offset_deg(radius_km, lat0) # recompute — same inputs, different rand # We can't recover the just-patched coords here, so derive # them deterministically from the row id hash so the # vehicle-level update matches what we actually stored. # (We re-pick a different random seed for stability.) else: errors.append({"id": rid, "code": code}) # Re-fetch zero_rows to learn the actual coords we stored, then # pick the latest fill per vehicle. print(f" PATCHed {successes}/{len(zero_rows)} fuel_log rows") if errors: print(f" ERRORS ({len(errors)}): {errors[:5]}") # Re-list to get post-update coords rows_after = list_fuel_logs_for_site_month(site_id, month_yyyymm) # Map id -> row rows_after_by_id = {r["id"]: r for r in rows_after} # Pick the latest non-zero fill per vehicle latest_per_vehicle: dict[str, dict] = {} for r in rows_after: if (r.get("latitude") in (None, 0)) and (r.get("longitude") in (None, 0)): continue vid = r.get("vehicle") if not vid: continue cur = latest_per_vehicle.get(vid) cur_d = cur.get("date_logged") if cur else "" if not cur or (r.get("date_logged") or "") > cur_d: latest_per_vehicle[vid] = r print(f" vehicles with stamped latest position: {len(latest_per_vehicle)}") # PATCH each affected vehicle's last-known coords. # The vehicles schema uses `last_latitude` / `last_longitude` / # `last_location_accuracy_m` (NOT `latitude` / `longitude`); the # latter are the per-fill fields on fuel_logs. v_success = 0 v_errors = [] with ThreadPoolExecutor(max_workers=16) as ex: futures = {} for vid, r in latest_per_vehicle.items(): fut = ex.submit( patch_record, "vehicles", vid, { "last_latitude": r.get("latitude"), "last_longitude": r.get("longitude"), "last_location_accuracy_m": radius_m, "last_scanned_at": r.get("date_logged") or "", }, ) futures[fut] = vid for fut in as_completed(futures): vid = futures[fut] _, code = fut.result() if code == 200: v_success += 1 else: v_errors.append({"id": vid, "code": code}) print(f" PATCHed {v_success}/{len(latest_per_vehicle)} vehicles") if v_errors: print(f" vehicle ERRORS ({len(v_errors)}): {v_errors[:5]}") return successes, v_success, { "site": site.get("name"), "fuel_logs_stamped": successes, "vehicles_updated": v_success, "radius_km": radius_km, } def main() -> int: p = argparse.ArgumentParser() p.add_argument("--radius-km", type=float, default=1.5) p.add_argument("--sites", nargs="+", default=["8qyqytyl62rysu1", "u6n5fye4czinw6o"]) p.add_argument("--month", default=None, help="YYYY-MM (default: current SAST month)") p.add_argument( "--force", action="store_true", help="Overwrite existing non-zero coords (use when tightening the radius).", ) args = p.parse_args() month = args.month if not month: # Africa/Johannesburg is UTC+2 year-round (no DST). Use that offset. from datetime import timedelta now_sast = datetime.now(timezone(timedelta(hours=2))) month = f"{now_sast.year}-{now_sast.month:02d}" print(f"Randomizing coords for {month} on {args.sites} within {args.radius_km} km radius") total_fl = 0 total_v = 0 for sid in args.sites: fl, v, _ = run(sid, month, args.radius_km, force=args.force) total_fl += fl total_v += v print(f"\n=== TOTAL ===") print(f" fuel_logs stamped: {total_fl}") print(f" vehicles updated: {total_v}") print(f" radius_km: {args.radius_km}") print(f" month: {month}") return 0 if __name__ == "__main__": sys.exit(main())