refactor: ditch Nominatim, use reverse_geocoder exclusively for geocoding

Nominatim was slow (rate-limited, 1 req/sec, ~4s timeouts) and unreliable
from Docker/Cloudflare (blocked or throttled). It was the root cause of
location labels failing to appear during trip scrubbing.

- server/app.py: _geocode_cached now uses reverse_geocoder exclusively
  (offline, instant, city/province level). Removed Nominatim HTTP calls,
  ThreadPoolExecutor, concurrent.futures import.
- app.py (Pi): same — reverse_geocoder imported at module level,
  Nominatim + urllib.request removed.
- Batch endpoint simplified to sequential loop with lru_cache + dedup.
  No network dependency at all — geocoding is instant and always works.

reverse_geocoder provides city/province labels (e.g. 'Montreal, Quebec')
which is the right granularity for trip scrubbing.
This commit is contained in:
Mathieu Moran
2026-08-12 03:27:00 -04:00
parent e60b7d7d36
commit 0ddfe39901
2 changed files with 23 additions and 67 deletions
+9 -22
View File
@@ -163,36 +163,23 @@ def api_delete_trip(trip_id):
return jsonify(status="deleted"), 200 return jsonify(status="deleted"), 200
import functools import functools
import urllib.request import reverse_geocoder as rg
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def _geocode_cached(lat4, lon4): def _geocode_cached(lat4, lon4):
"""Try Nominatim first (street-level), fall back to reverse_geocoder (city), """Reverse-geocode a coordinate to city/province using the offline
then raw coordinates. Always returns a non-empty label string.""" reverse_geocoder library. Always returns a non-empty label string."""
lat = lat4 / 10000.0 lat = lat4 / 10000.0
lon = lon4 / 10000.0 lon = lon4 / 10000.0
try: try:
url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}&accept-language=en" results = rg.search([(lat, lon)], mode=1, verbose=False)
req = urllib.request.Request(url, headers={"User-Agent": "yaris-obd/1.0"}) if results:
with urllib.request.urlopen(req, timeout=4) as resp: r = results[0]
data = json.loads(resp.read()) label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
a = data.get("address", {}) if label:
road = a.get("road") or a.get("hamlet") or a.get("suburb") or "" return label
city = a.get("city") or a.get("town") or a.get("village") or a.get("municipality") or ""
label = ", ".join(p for p in [road, city] if p)
if label:
return label
except Exception: except Exception:
pass pass
# Offline fallback
import reverse_geocoder as rg
results = rg.search([(lat, lon)], mode=1, verbose=False)
if results:
r = results[0]
label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
if label:
return label
# Last resort — raw coordinates so the frontend never gets an empty label
return f"{lat:.5f}, {lon:.5f}" return f"{lat:.5f}, {lon:.5f}"
@app.route('/api/geocode') @app.route('/api/geocode')
+14 -45
View File
@@ -2,7 +2,6 @@
uploaded by the Pi, storing them in Postgres + on-disk file storage. uploaded by the Pi, storing them in Postgres + on-disk file storage.
Also serves the remote web portal for browsing and managing the configured car. Also serves the remote web portal for browsing and managing the configured car.
""" """
import concurrent.futures
import functools import functools
import hashlib import hashlib
import os import os
@@ -21,45 +20,28 @@ from modules.user_auth import login_manager, User, check_password, require_login
from modules import device_config, device_registry from modules import device_config, device_registry
# ── Geocode cache ────────────────────────────────────────────────────────── # ── Geocode cache ──────────────────────────────────────────────────────────
try: # reverse_geocoder is an offline Python library that maps lat/lon →
import reverse_geocoder as _rg # city/province using a precomputed gazetteer (~40MB). Instant, no network,
_RG_AVAILABLE = True # always available — a much better fit for trip scrubbing than Nominatim
except ImportError: # which is slow, rate-limited, and often unreachable from Docker/Cloudflare.
_RG_AVAILABLE = False import reverse_geocoder as _rg
@functools.lru_cache(maxsize=512) @functools.lru_cache(maxsize=512)
def _geocode_cached(lat4, lon4): def _geocode_cached(lat4, lon4):
"""Try Nominatim first (street-level), fall back to reverse_geocoder (city), """Reverse-geocode a coordinate to city/province using the offline
then raw coordinates. Always returns a non-empty label string — never None.""" reverse_geocoder library. Always returns a non-empty label string."""
lat = lat4 / 10000.0 lat = lat4 / 10000.0
lon = lon4 / 10000.0 lon = lon4 / 10000.0
try: try:
import urllib.request, urllib.error, json as _json
url = (
f"https://nominatim.openstreetmap.org/reverse"
f"?lat={lat}&lon={lon}&format=jsonv2&accept-language=en"
)
req = urllib.request.Request(url, headers={"User-Agent": "yaris-obd-server/1.0"})
with urllib.request.urlopen(req, timeout=4) as resp:
data = _json.loads(resp.read())
a = data.get("address", {})
road = a.get("road") or a.get("hamlet") or a.get("suburb") or ""
city = a.get("city") or a.get("town") or a.get("village") or a.get("municipality") or ""
label = ", ".join(p for p in [road, city] if p)
if label:
return label
except Exception:
pass
# Offline fallback
if _RG_AVAILABLE:
results = _rg.search([(lat, lon)], mode=1, verbose=False) results = _rg.search([(lat, lon)], mode=1, verbose=False)
if results: if results:
r = results[0] r = results[0]
label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p) label = ", ".join(p for p in [r.get("name", ""), r.get("admin1", "")] if p)
if label: if label:
return label return label
# Last resort — raw coordinates so the frontend never gets an empty label except Exception:
pass
return f"{lat:.5f}, {lon:.5f}" return f"{lat:.5f}, {lon:.5f}"
@@ -629,16 +611,14 @@ def geocode():
def geocode_batch(): def geocode_batch():
"""Batch geocode — returns {key: label} for all unique coords in one request. """Batch geocode — returns {key: label} for all unique coords in one request.
Parallelised with ThreadPoolExecutor (max_workers=4) so Nominatim HTTP Uses the offline reverse_geocoder library (instant, no network). Deduplicates
calls and reverse_geocoder lookups run concurrently instead of sequentially. coords before lookup; lru_cache absorbs any remaining duplicates."""
_geocode_cached's lru_cache absorbs duplicate coords within the batch."""
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
coords = body.get("coords") coords = body.get("coords")
if not isinstance(coords, list) or not coords: if not isinstance(coords, list) or not coords:
return jsonify(error="expected {coords: [{lat, lon}, ...]}"), 400 return jsonify(error="expected {coords: [{lat, lon}, ...]}"), 400
# Deduplicate — same (lat4,lon4) would hit lru_cache anyway, but this # Deduplicate
# avoids submitting redundant futures.
seen = set() seen = set()
items = [] items = []
for item in coords: for item in coords:
@@ -654,19 +634,8 @@ def geocode_batch():
items.append((f"{lat:.3f},{lon:.3f}", lat4, lon4)) items.append((f"{lat:.3f},{lon:.3f}", lat4, lon4))
result = {} result = {}
if not items: for key, lat4, lon4 in items:
return jsonify(result) result[key] = _geocode_cached(lat4, lon4)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
futures = {ex.submit(_geocode_cached, lat4, lon4): key for key, lat4, lon4 in items}
for future in concurrent.futures.as_completed(futures):
key = futures[future]
try:
result[key] = future.result(timeout=8)
except Exception:
# Last resort — raw coords if even the fallback failed
parts = key.split(",")
result[key] = f"{float(parts[0]):.5f}, {float(parts[1]):.5f}"
return jsonify(result) return jsonify(result)