Files
obdash/obdcore/trip.py
T
justin 6c1ee0c81d Section 1 backend: VIN/Mode-09, readiness monitors, freeze-frame, trip/perf
obdcore additions (all standard SAE J1979, vehicle-agnostic, hardware-free
tested):
- obdservices.py: decode_vin (Mode 09), decode_readiness (Mode 01 PID 01 I-M
  monitors + MIL + DTC count, spark/diesel monitor sets), freeze-frame PID set.
- link.py: ElmLink.read_vehicle_info (VIN/cal/ECU), read_readiness, read_freeze_frame.
- trip.py: TripComputer (MAF-based MPG + trip totals) and PerformanceMeter
  (0-60 / 1/4-mile with launch detection).
- mock.py: speed/MAF/readiness + service stubs for GUI mock mode.
- tests/test_services.py: VIN, readiness bit decode, trip math, 0-60/quarter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 19:37:48 -04:00

120 lines
3.9 KiB
Python

"""Trip computer + performance timers (computed from live data, no serial).
TripComputer estimates fuel economy and trip totals from vehicle speed (km/h)
and MAF (g/s) -- the same MAF-based estimate Torque/etc. use. PerformanceMeter
measures 0-60 mph and 1/4-mile times, auto-detecting a launch from a near-stop.
Both are fed time-stamped samples and are unit tested without hardware.
"""
KMH_PER_MPH = 1.609344
GAS_AFR = 14.7 # stoichiometric air:fuel for gasoline
GAS_G_PER_GAL = 2835.0 # ~6.25 lb/gal
def _gph(maf_gps, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL):
return (maf_gps / afr) * 3600.0 / g_per_gal
class TripComputer:
def __init__(self, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL):
self.afr, self.g_per_gal = afr, g_per_gal
self.reset()
def reset(self):
self.dist_km = 0.0
self.fuel_gal = 0.0
self.moving_s = 0.0
self.elapsed_s = 0.0
self._t = None
def update(self, t, speed_kmh, maf_gps):
if self._t is None:
self._t = t
return
dt = t - self._t
self._t = t
if dt <= 0 or dt > 5: # ignore gaps / first sample
return
self.elapsed_s += dt
if speed_kmh:
self.dist_km += speed_kmh * dt / 3600.0
if speed_kmh > 1.0:
self.moving_s += dt
if maf_gps:
self.fuel_gal += _gph(maf_gps, self.afr, self.g_per_gal) * dt / 3600.0
def instant_mpg(self, speed_kmh, maf_gps):
if not speed_kmh or not maf_gps:
return 0.0
gph = _gph(maf_gps, self.afr, self.g_per_gal)
return (speed_kmh / KMH_PER_MPH) / gph if gph > 0 else 0.0
def avg_mpg(self):
miles = self.dist_km / KMH_PER_MPH
return miles / self.fuel_gal if self.fuel_gal > 0 else 0.0
def stats(self):
return {
"distance_mi": round(self.dist_km / KMH_PER_MPH, 2),
"fuel_gal": round(self.fuel_gal, 3),
"avg_mpg": round(self.avg_mpg(), 1),
"elapsed_s": round(self.elapsed_s, 1),
"moving_s": round(self.moving_s, 1),
}
class PerformanceMeter:
"""0-60 mph and 1/4-mile timers. A run starts when the car pulls away from a
near-stop and ends at the 1/4 mile or when it stops; best times are kept."""
QUARTER_M = 402.336
def __init__(self):
self.best_0_60 = None
self.best_quarter = None
self.last_0_60 = None
self.last_quarter = None
self._reset_run()
def _reset_run(self):
self._running = False
self._t0 = None
self._dist_m = 0.0
self._last_t = None
self._stop_t = None
self._did60 = False
def update(self, t, speed_kmh):
if speed_kmh is None:
return
mph = speed_kmh / KMH_PER_MPH
if not self._running:
if mph < 1.0:
self._stop_t = t # parked, ready to launch
elif self._stop_t is not None:
self._running = True # launch detected
self._t0 = self._stop_t
self._dist_m = 0.0
self._last_t = t
self._did60 = False
return
dt = t - self._last_t
self._last_t = t
if dt <= 0 or dt > 5:
self._reset_run()
return
self._dist_m += (speed_kmh / 3.6) * dt
if mph >= 60 and not self._did60:
self._did60 = True
self.last_0_60 = round(t - self._t0, 2)
if self.best_0_60 is None or self.last_0_60 < self.best_0_60:
self.best_0_60 = self.last_0_60
if self._dist_m >= self.QUARTER_M:
self.last_quarter = round(t - self._t0, 2)
if self.best_quarter is None or self.last_quarter < self.best_quarter:
self.best_quarter = self.last_quarter
self._reset_run()
elif mph < 1.0:
self._reset_run()