perf: geocode pipeline — batch POST + parallel server, 200→1 roundtrip

JS preload(): replaced N individual GET /api/geocode (concurrency 2) with
a single POST /api/geocode/batch — one HTTP roundtrip instead of up to
N/2 sequential ones. schedLoc debounce reduced 600→200ms.

Server geocode_batch(): ThreadPoolExecutor(max_workers=4) parallelises
Nominatim/reverse_geocoder calls instead of sequential loop. Deduplicates
coords before submitting futures. lru_cache absorbs duplicates within batch.

Pi geocode_batch_route(): deduplicate coords, removed stale 'if label:'
guard (label always non-empty after BUG-13 fix).
This commit is contained in:
Mathieu Moran
2026-08-12 03:15:30 -04:00
parent ad10a873b4
commit e60b7d7d36
3 changed files with 54 additions and 25 deletions
+6 -3
View File
@@ -212,6 +212,9 @@ def geocode_batch_route():
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 avoids
# submitting redundant work.
seen = set()
result = {} result = {}
for item in coords: for item in coords:
try: try:
@@ -221,9 +224,9 @@ def geocode_batch_route():
continue continue
lat4 = int(round(lat, 4) * 10000) lat4 = int(round(lat, 4) * 10000)
lon4 = int(round(lon, 4) * 10000) lon4 = int(round(lon, 4) * 10000)
label = _geocode_cached(lat4, lon4) if (lat4, lon4) not in seen:
if label: seen.add((lat4, lon4))
result[f"{lat:.3f},{lon:.3f}"] = label result[f"{lat:.3f},{lon:.3f}"] = _geocode_cached(lat4, lon4)
return jsonify(result) return jsonify(result)
@app.route('/api/dash/devices') @app.route('/api/dash/devices')
+30 -5
View File
@@ -2,6 +2,7 @@
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
@@ -626,12 +627,20 @@ def geocode():
@app.post("/api/geocode/batch") @app.post("/api/geocode/batch")
@require_login @require_login
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
calls and reverse_geocoder lookups run concurrently instead of sequentially.
_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
result = {}
# Deduplicate — same (lat4,lon4) would hit lru_cache anyway, but this
# avoids submitting redundant futures.
seen = set()
items = []
for item in coords: for item in coords:
try: try:
lat = float(item["lat"]) lat = float(item["lat"])
@@ -640,9 +649,25 @@ def geocode_batch():
continue continue
lat4 = int(round(lat, 4) * 10000) lat4 = int(round(lat, 4) * 10000)
lon4 = int(round(lon, 4) * 10000) lon4 = int(round(lon, 4) * 10000)
key = f"{lat:.3f},{lon:.3f}" if (lat4, lon4) not in seen:
name = _geocode_cached(lat4, lon4) seen.add((lat4, lon4))
result[key] = name items.append((f"{lat:.3f},{lon:.3f}", lat4, lon4))
result = {}
if not items:
return jsonify(result)
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)
+18 -17
View File
@@ -75,31 +75,32 @@
function preload(pts) { function preload(pts) {
if (_geoCtrl) _geoCtrl.abort(); if (_geoCtrl) _geoCtrl.abort();
_geoCtrl = new AbortController(); var sig = _geoCtrl.signal; _cache = {}; _geoCtrl = new AbortController(); var sig = _geoCtrl.signal; _cache = {};
var seen = {}, q = [], i; var seen = {}, coords = [], i;
for (i = 0; i < pts.length; i++) { for (i = 0; i < pts.length; i++) {
var k = gk(pts[i].latitude, pts[i].longitude); var k = gk(pts[i].latitude, pts[i].longitude);
if (!k || seen[k]) continue; seen[k] = 1; if (!k || seen[k]) continue; seen[k] = 1;
q.push({k: k, lat: pts[i].latitude, lon: pts[i].longitude}); coords.push({lat: pts[i].latitude, lon: pts[i].longitude});
} }
if (!q.length) return; if (!coords.length) return;
var C = 2, idx = 0; // Single batch POST instead of N individual GETs — one roundtrip.
function nx() { fetch("/api/geocode/batch", {
if (idx >= q.length || sig.aborted) return; method: "POST",
var it = q[idx++], c = new AbortController(); headers: {"Content-Type": "application/json"},
var t = setTimeout(function () { c.abort(); }, 3000); body: JSON.stringify({coords: coords}),
sig.addEventListener("abort", function () { c.abort(); }); signal: sig
fetch("/api/geocode?lat=" + it.lat + "&lon=" + it.lon, {signal: c.signal}) })
.then(function (r) { clearTimeout(t); return r.ok ? r.json() : null; }) .then(function (r) { return r.ok ? r.json() : {}; })
.then(function (d) { if (!sig.aborted) _cache[it.k] = (d && d.label) || null; }) .then(function (data) {
.catch(function () { clearTimeout(t); }) if (sig.aborted) return;
.finally(function () { if (!sig.aborted) nx(); }); for (var key in data) { if (data.hasOwnProperty(key)) _cache[key] = data[key]; }
} })
for (var j = 0; j < C && j < q.length; j++) nx(); .catch(function () {});
} }
function showLoc(lat, lon) { function showLoc(lat, lon) {
var k = gk(lat, lon); if (!k || k === _lastKey) return; _lastKey = k; var k = gk(lat, lon); if (!k || k === _lastKey) return; _lastKey = k;
var fb = lat.toFixed(5) + ", " + lon.toFixed(5); var fb = lat.toFixed(5) + ", " + lon.toFixed(5);
if (_cache.hasOwnProperty(k)) { locEl.textContent = _cache[k] || fb; return; } if (_cache.hasOwnProperty(k)) { locEl.textContent = _cache[k] || fb; return; }
// Fallback: individual fetch only if preload didn't cover this coord.
var c = new AbortController(); var c = new AbortController();
var t = setTimeout(function () { c.abort(); }, 3000); var t = setTimeout(function () { c.abort(); }, 3000);
fetch("/api/geocode?lat=" + lat + "&lon=" + lon, {signal: c.signal}) fetch("/api/geocode?lat=" + lat + "&lon=" + lon, {signal: c.signal})
@@ -112,7 +113,7 @@
} }
function schedLoc(lat, lon) { function schedLoc(lat, lon) {
clearTimeout(_geoTimer); clearTimeout(_geoTimer);
_geoTimer = setTimeout(function () { showLoc(lat, lon); }, 600); _geoTimer = setTimeout(function () { showLoc(lat, lon); }, 200);
} }
// ── Format ─────────────────────────────────────────────────────────── // ── Format ───────────────────────────────────────────────────────────