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:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user