6c1ee0c81d
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
120 lines
3.9 KiB
Python
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()
|