fix(phase26): geocode pre-caching (BUG-09) + gate trips on OBD presence (BUG-10)
This commit is contained in:
@@ -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
|
## Resolved bugs
|
||||||
|
|
||||||
### BUG-03 — DTC/MIL codes are never persisted; lost on OBD disconnect or reboot
|
### BUG-03 — DTC/MIL codes are never persisted; lost on OBD disconnect or reboot
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ stop_rpm = Event()
|
|||||||
stop_sensors = Event()
|
stop_sensors = Event()
|
||||||
|
|
||||||
connection = None
|
connection = None
|
||||||
|
_car_ever_connected = False
|
||||||
|
|
||||||
|
|
||||||
def _decode_odometer(messages):
|
def _decode_odometer(messages):
|
||||||
@@ -228,6 +229,8 @@ def connect_obd():
|
|||||||
|
|
||||||
with _connection_lock:
|
with _connection_lock:
|
||||||
connection = candidate
|
connection = candidate
|
||||||
|
if candidate is not None:
|
||||||
|
_car_ever_connected = True
|
||||||
|
|
||||||
if candidate is None:
|
if candidate is None:
|
||||||
_emit_disconnected()
|
_emit_disconnected()
|
||||||
@@ -268,6 +271,11 @@ def get_fresh_speed(max_age=5):
|
|||||||
def obd_connection():
|
def obd_connection():
|
||||||
"""Emit current state for a newly connected browser and ensure OBD is running."""
|
"""Emit current state for a newly connected browser and ensure OBD is running."""
|
||||||
start_obd()
|
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():
|
if _is_car_connected():
|
||||||
app_events.socketio.emit("obd_status", obd_status[1])
|
app_events.socketio.emit("obd_status", obd_status[1])
|
||||||
app_events.socketio.emit("obd_speed", get_speedtime())
|
app_events.socketio.emit("obd_speed", get_speedtime())
|
||||||
|
|||||||
@@ -81,6 +81,12 @@ def build_trip_summaries(raw_rows):
|
|||||||
if len(rows) < 2:
|
if len(rows) < 2:
|
||||||
return []
|
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 = []
|
obd_motion = []
|
||||||
gps_motion = []
|
gps_motion = []
|
||||||
for index in range(1, len(rows)):
|
for index in range(1, len(rows)):
|
||||||
|
|||||||
+52
-3
@@ -33,17 +33,65 @@
|
|||||||
|
|
||||||
let _geoTimer = null;
|
let _geoTimer = null;
|
||||||
let _lastGeoKey = 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) {
|
function geocodePoint(lat, lon) {
|
||||||
const key = `${lat.toFixed(3)},${lon.toFixed(3)}`;
|
const key = _geoKey(lat, lon);
|
||||||
if (key === _lastGeoKey) return;
|
if (key === _lastGeoKey) return;
|
||||||
_lastGeoKey = key;
|
_lastGeoKey = key;
|
||||||
const fallback = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
|
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 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 })
|
fetch(`/api/geocode?lat=${lat}&lon=${lon}`, { signal: ctrl.signal })
|
||||||
.then(r => { clearTimeout(t); return r.ok ? r.json() : null; })
|
.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; });
|
.catch(() => { clearTimeout(t); replayLocation.textContent = fallback; });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +225,7 @@
|
|||||||
+ `avg ${formatSpeed(result.trip.average_speed_kmh)} · `
|
+ `avg ${formatSpeed(result.trip.average_speed_kmh)} · `
|
||||||
+ `max ${formatSpeed(result.trip.max_speed_kmh)} · `
|
+ `max ${formatSpeed(result.trip.max_speed_kmh)} · `
|
||||||
+ `${result.points.length} route points`;
|
+ `${result.points.length} route points`;
|
||||||
|
preloadGeocodes(result.points);
|
||||||
initializeTimeline(result.points);
|
initializeTimeline(result.points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user