diff --git a/BUGS.md b/BUGS.md index a31092a..c16d83d 100644 --- a/BUGS.md +++ b/BUGS.md @@ -157,6 +157,47 @@ the authoritative, issue-by-issue record lives **here**. --- +### BUG-09 — Server trip scrubber geocoding is slow on each step +- **Status**: `OPEN` +- **Severity**: Medium (UX) +- **Area**: `static/js/history.js` `showReplayPoint()` / `scheduleGeocode()` +- **Phase**: Phase 26 +- **Symptom**: Scrubbing through the trip timeline on the server portal is sluggish — the location + name takes a long time to appear because each scrub step fires a fresh `/api/geocode` HTTP + request (debounced 600ms). On the Pi this is fast (offline reverse_geocoder), but the server + calls Nominatim which adds latency. +- **Root cause**: Every `showReplayPoint()` call triggers `scheduleGeocode()` → debounced + `fetch(/api/geocode...)`. No pre-caching or batch loading. +- **Fix scope / actions**: Pre-fetch geocode results for ALL trip points when the route is first + loaded. Store in a local JS map keyed by rounded coordinate. Show cached label instantly during + scrubbing. Only fall back to live fetch for uncached points. Optionally batch-geocode at the + server API level too (accept multiple lat/lon pairs). +- **Acceptance**: Scrubbing through trip replay shows location names near-instantly. + +### BUG-10 — GPS-only false trips generated when car is not connected +- **Status**: `OPEN` +- **Severity**: Medium (data quality) +- **Area**: `modules/app_trip.py` `build_trip_summaries()`, `modules/app_sql.py` `refresh_trips()` +- **Phase**: Phase 26 +- **Symptom**: The Pi generates trips from GPS data alone (GPS jitter/drift) even when the car + isn't connected. Since the Pi bench-tests with no OBD, GPS displacement fallback creates + phantom "trips" from stationary GPS noise. +- **Root cause**: The GPS motion fallback (3+ consecutive 8m displacements) can be satisfied by + GPS jitter over time, especially indoors or in weak-signal areas. The system doesn't gate trip + detection on whether OBD was ever present. +- **Fix scope / actions**: + - **Option A (strict)**: Only start trips when OBD wheel speed has been detected at least once + since the app started (a `car_was_connected` flag). No OBD ever = no trips, even with GPS. + - **Option B (lenient)**: Increase GPS motion requirements when OBD has never been seen (e.g. + require 10+ consecutive displacements of 20m+ instead of 3×8m). + - **Recommend Option A** — it matches the real-world use case: the Pi is powered by the car's + ignition, so OBD should always be reachable when the car is running. No OBD = bench test, + don't generate trips. +- **Acceptance**: Bench-testing the Pi (no car connected) produces zero trips. Real driving with + OBD connected still produces correct trips. + +--- + ## Resolved bugs ### BUG-03 — DTC/MIL codes are never persisted; lost on OBD disconnect or reboot diff --git a/modules/app_obd.py b/modules/app_obd.py index 3a88bc8..c407917 100644 --- a/modules/app_obd.py +++ b/modules/app_obd.py @@ -49,6 +49,7 @@ stop_rpm = Event() stop_sensors = Event() connection = None +_car_ever_connected = False def _decode_odometer(messages): @@ -228,6 +229,8 @@ def connect_obd(): with _connection_lock: connection = candidate + if candidate is not None: + _car_ever_connected = True if candidate is None: _emit_disconnected() @@ -268,6 +271,11 @@ def get_fresh_speed(max_age=5): def obd_connection(): """Emit current state for a newly connected browser and ensure OBD is running.""" start_obd() + + +def was_car_ever_connected(): + """True if the car ECU has been reached at least once this session.""" + return _car_ever_connected if _is_car_connected(): app_events.socketio.emit("obd_status", obd_status[1]) app_events.socketio.emit("obd_speed", get_speedtime()) diff --git a/modules/app_trip.py b/modules/app_trip.py index a5b9ad0..ae1729f 100644 --- a/modules/app_trip.py +++ b/modules/app_trip.py @@ -81,6 +81,12 @@ def build_trip_summaries(raw_rows): if len(rows) < 2: return [] + # Don't generate trips from GPS drift when the car was never connected. + # Without OBD, GPS jitter can produce false motion events on the bench. + from modules import app_obd + if not app_obd.was_car_ever_connected(): + return [] + obd_motion = [] gps_motion = [] for index in range(1, len(rows)): diff --git a/static/js/history.js b/static/js/history.js index 0111c74..e692b4d 100644 --- a/static/js/history.js +++ b/static/js/history.js @@ -33,17 +33,65 @@ let _geoTimer = null; let _lastGeoKey = null; + let _geoCache = {}; // key → label, pre-populated on trip load + + function _geoKey(lat, lon) { + return `${lat.toFixed(3)},${lon.toFixed(3)}`; + } + + function preloadGeocodes(points) { + // Collect unique rounded coordinates and batch-fetch all labels upfront. + _geoCache = {}; + const seen = new Set(); + const queue = []; + for (const p of points) { + const key = _geoKey(p.latitude, p.longitude); + if (seen.has(key)) continue; + seen.add(key); + queue.push({ key, lat: p.latitude, lon: p.longitude }); + } + + // Fetch in parallel with a small concurrency cap + const CONCURRENCY = 4; + let idx = 0; + function next() { + if (idx >= queue.length) return; + const item = queue[idx++]; + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 4000); + return fetch(`/api/geocode?lat=${item.lat}&lon=${item.lon}`, { signal: ctrl.signal }) + .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) + .then(data => { + _geoCache[item.key] = (data && data.label) || null; + }) + .catch(() => { clearTimeout(t); _geoCache[item.key] = null; }) + .finally(() => next()); + } + // Start initial batch + for (let i = 0; i < CONCURRENCY && i < queue.length; i++) next(); + } function geocodePoint(lat, lon) { - const key = `${lat.toFixed(3)},${lon.toFixed(3)}`; + const key = _geoKey(lat, lon); if (key === _lastGeoKey) return; _lastGeoKey = key; const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + + // Use cache if available + if (_geoCache.hasOwnProperty(key)) { + replayLocation.textContent = _geoCache[key] || fallback; + return; + } + const ctrl = new AbortController(); - const t = setTimeout(() => ctrl.abort(), 6000); + const t = setTimeout(() => ctrl.abort(), 4000); fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal }) .then(r => { clearTimeout(t); return r.ok ? r.json() : null; }) - .then(data => { replayLocation.textContent = (data && data.label) ? data.label : fallback; }) + .then(data => { + const label = (data && data.label) || fallback; + _geoCache[key] = (data && data.label) || null; + replayLocation.textContent = label; + }) .catch(() => { clearTimeout(t); replayLocation.textContent = fallback; }); } @@ -177,6 +225,7 @@ + `avg ${formatSpeed(result.trip.average_speed_kmh)} · ` + `max ${formatSpeed(result.trip.max_speed_kmh)} · ` + `${result.points.length} route points`; + preloadGeocodes(result.points); initializeTimeline(result.points); }