78a8017e95
DER VERBRAUCH WAR SYSTEMATISCH ZU HOCH Der Startwert kam aus wert_bei(), also dem letzten Literstand VOR der Fahrt - und das ist im Stand der Wert vom Ende der VORIGEN Fahrt. An der Fahrt Eichstaett - Adelschlag nachgemessen: 36,4 l um 13:52 (Ende der vorigen Fahrt), 36,2 l um 16:20:49 (erster Datensatz der neuen). In der Standzeit dazwischen ist niemand gefahren; diese 0,2 l Setzung wurden der neuen Fahrt zugerechnet. alt: 36,4 - 35,6 = 0,8 l auf 6,896 km -> 11,6 l/100 km neu: 36,2 - 35,6 = 0,6 l auf 6,896 km -> 8,7 l/100 km Neu wert_ab() in verlauf.py: der erste Wert AB dem Zeitpunkt. Fuer einen ZAEHLER ist "zuletzt davor" richtig (der Kilometerstand aendert sich im Stand nicht), fuer einen gemessenen FUELLSTAND nicht - der driftet. Das Fahrtende bleibt bei wert_bei(). Livepfad und Rueckblick benutzen beide die neue Funktion. Bereits gerechnete Fahrten behalten ihren Wert: _verbrauch_screenen() fuellt nur fehlende. Die Neuberechnung kommt mit dem Korrekturwert aus den Tankvorgaengen, den der Eigentuemer vorgeschlagen hat. ANZEIGE Hoechstgeschwindigkeit ohne Nachkommastelle. Statuswort raus aus der Fahrtenliste - "vollstaendig" sagt dem Nutzer nichts, und "offen" verspricht Daten, die bei einer laengst abgeschlossenen Fahrt nie mehr kommen; fehlt ein Wert, steht dort ein Strich. Strecke und Verbrauch stehen jetzt in einer Spalte: die Wertspalte wuchs mit dem Inhalt, gemessen 86px gegen 37px, und die Zahlen standen versetzt. Feste Mindestbreite in beiden Oberflaechen, nachgemessen alle Zeilen 92px. Verifiziert: py_compile, Panel als Modul, tsc --noEmit sauber, 165/165 Tests, wert_ab() gegen vier Randfaelle, im Browser nachgesehen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
543 lines
23 KiB
Python
543 lines
23 KiB
Python
"""Lesezugriff auf den aufgezeichneten Zustandsverlauf (recorder).
|
|
|
|
Über Home Assistants eigene recorder-API
|
|
(`homeassistant.components.recorder.history.get_significant_states`), nicht
|
|
über direkten SQL-Zugriff auf home-assistant_v2.db: das Datenbankschema des
|
|
recorders ist HA-intern und ändert sich zwischen Versionen, die Funktion
|
|
dagegen ist die von HA selbst benutzte und stabile Schnittstelle.
|
|
|
|
`significant_changes_only=False` ist wichtig: bei numerischen Sensoren
|
|
(Kilometerstand, Tankfüllstand) liefert der Standardmodus nur "auffällige"
|
|
Änderungen und verschluckt genau die kleinen Schritte, aus denen sich
|
|
Fahrstrecke und Tankvorgänge zusammensetzen.
|
|
|
|
WAS HIER GEGENÜBER DER PYSCRIPT-FASSUNG WEGFÄLLT: das Fahrtabschluss-
|
|
Screening las den Verlauf früher über die HTTP-REST-API (/api/history/period)
|
|
und brauchte dafür ein langlebiges Zugriffstoken in
|
|
audi_dashboard/ha_token.txt - ein eigener Installationsschritt, der bei jeder
|
|
Neuinstallation vergessen werden konnte und dessen Fehlen sich nur als
|
|
Warnung im Protokoll zeigte (Fahrten blieben dann stumm ohne Strecke). Als
|
|
echte Integration liest die App den Verlauf direkt; Token und
|
|
Installationsschritt entfallen ersatzlos.
|
|
|
|
Die recorder-Abfrage läuft über den Executor: ein Datenbankzugriff hat im
|
|
Event-Loop nichts verloren.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
|
|
import asyncio
|
|
|
|
from homeassistant.components.recorder import get_instance, history
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
Verlaufspunkt = tuple[datetime.datetime, str]
|
|
|
|
|
|
def _rohverlauf(
|
|
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
|
|
) -> list[Verlaufspunkt]:
|
|
roh = history.get_significant_states(
|
|
hass, start, ende, [entity_id], None, True, False
|
|
)
|
|
ergebnis: list[Verlaufspunkt] = []
|
|
for zustand in roh.get(entity_id) or []:
|
|
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
|
|
# würden sie durchgereicht, ergäbe ein Ausfall der Datenquelle eine
|
|
# Fahrt mit absurder Kilometerdifferenz.
|
|
if zustand.state in ("unknown", "unavailable", None, ""):
|
|
continue
|
|
ergebnis.append((zustand.last_updated, zustand.state))
|
|
ergebnis.sort(key=lambda p: p[0])
|
|
return ergebnis
|
|
|
|
|
|
async def verlauf_lesen(
|
|
hass: HomeAssistant,
|
|
entity_id: str | None,
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> list[Verlaufspunkt]:
|
|
"""Zustandsverlauf einer Entität als aufsteigende Liste von
|
|
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
|
|
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
|
|
if not entity_id:
|
|
return []
|
|
try:
|
|
return await get_instance(hass).async_add_executor_job(
|
|
_rohverlauf, hass, entity_id, start, ende
|
|
)
|
|
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
|
|
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
|
|
# beides ist verkraftbar. Den aufrufenden Ablauf deswegen abzubrechen
|
|
# wäre es nicht.
|
|
_LOGGER.warning("Verlauf von %s nicht lesbar", entity_id, exc_info=True)
|
|
return []
|
|
|
|
|
|
def fahrtsignal(werte: object) -> str:
|
|
"""Die Entität, die Fahrtbeginn und -ende auslöst.
|
|
|
|
Der Trip-Status des Geräts, wenn er zugeordnet ist - sonst die Zündung,
|
|
wie vor der Trennung der beiden Rollen. Damit laufen bestehende
|
|
Installationen unverändert weiter, ohne dass jemand etwas zuordnen muss.
|
|
|
|
Eine gemeinsame Stelle, weil sonst Livepfad (fahrterkennung.py) und
|
|
Rückblick (historienimport.py) auseinanderlaufen könnten - dieselbe Regel
|
|
wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S."""
|
|
return getattr(werte, "TRIP_SENSOR", "") or getattr(werte, "ZUENDUNG_SENSOR", "")
|
|
|
|
|
|
def zeit_normalisiert(wert: object) -> datetime.datetime | None:
|
|
"""Akzeptiert ISO-Zeit mit oder ohne Zeitzone und normalisiert auf UTC.
|
|
|
|
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - die Oberfläche
|
|
schickt sowohl bei manueller Eingabe (Fahrt/Tankvorgang von Hand anlegen
|
|
oder bearbeiten, fahrterkennung.py/belege.py) als auch beim rückwirkenden
|
|
Import (historienimport.py) Ortszeit, nie UTC. Ohne diese Normalisierung
|
|
bliebe ein von Hand erfasster Zeitstempel naiv (kein tzinfo) - und
|
|
historienimport.py's eigene Dublettenprüfung (_ueberschneidet() für
|
|
Fahrten, bekannte_zeiten für Tankvorgänge) überspringt jeden Datensatz mit
|
|
naivem Zeitstempel stillschweigend, statt ihn auf Überschneidung zu
|
|
prüfen. Ein von Hand angelegter oder bearbeiteter Datensatz wäre dadurch
|
|
nie vor einem doppelten Import geschützt - genau die Lücke, die diese
|
|
gemeinsame Funktion schließt, indem jeder Schreibpfad dieselbe
|
|
Normalisierung durchläuft."""
|
|
if not wert:
|
|
return None
|
|
ts = datetime.datetime.fromisoformat(str(wert))
|
|
if ts.tzinfo is None:
|
|
ts = ts.astimezone()
|
|
return ts.astimezone(datetime.UTC)
|
|
|
|
|
|
def zahl(wert: object) -> float | None:
|
|
try:
|
|
return float(wert) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
|
|
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
|
|
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
|
|
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
|
|
# wert_bei()/naechster_wert()-Treffer über eine Lücke hinweg). Gemeinsam für
|
|
# den Import (historienimport.py) und die Live-Vervollständigung
|
|
# (screening.py) - beide dürfen dieselbe Strecke nie unterschiedlich
|
|
# bewerten.
|
|
UNPLAUSIBLE_KMH = 300
|
|
|
|
# Kuerzer als das ist Zuendung ohne Fahrt: Radio, Tuer oeffnen, Diagnose - oder,
|
|
# seit die Pausentoleranz entfallen ist, ein prellender Zuendungseingang. Am
|
|
# 31.08.2026 sind daraus an einem einzigen Tag elf Fahrten mit 0 km entstanden,
|
|
# zwei davon mit null Sekunden Dauer.
|
|
#
|
|
# Die Zahl stand vorher nur in historienimport.py, dessen eigener Kommentar den
|
|
# Widerspruch sogar benannte ("die Live-Erkennung legt sie zwar an"): derselbe
|
|
# Vorgang wurde beim Import verworfen und live gespeichert. Sie lebt deshalb
|
|
# jetzt hier, gemeinsam mit UNPLAUSIBLE_KMH und aus demselben Grund - beide
|
|
# Wege duerfen denselben Vorgang nie unterschiedlich bewerten.
|
|
MINDESTDAUER_S = 60
|
|
|
|
|
|
def durchschnitt_kmh(distanz_km: float | None, dauer_s: float | None) -> float | None:
|
|
"""km/h aus Strecke und Dauer, oder None wenn eine der beiden fehlt oder
|
|
die Dauer nicht positiv ist."""
|
|
if distanz_km is None or not dauer_s or dauer_s <= 0:
|
|
return None
|
|
return round(distanz_km / (dauer_s / 3600.0), 1)
|
|
|
|
|
|
# Wie lange auf die Meldezeit desselben Datensatzes gewartet wird.
|
|
#
|
|
# Die flespi-Integration setzt die Entitäten eines Datensatzes nacheinander:
|
|
# am 01.09.2026 gemessen stand die Zündung um 07:35:23.633 und die zugehörige
|
|
# Meldezeit 2 ms später um 07:35:23.635. Ohne diese kurze Wartezeit läse man
|
|
# den Zeitstempel des VORIGEN Datensatzes - beim Fahren zehn Sekunden daneben,
|
|
# im Stand aber möglicherweise Stunden, weil das Gerät dort nur alle paar
|
|
# Stunden einen Satz schreibt (Data Acquisition, On Stop: 43200 s).
|
|
MELDEZEIT_FRIST_S = 2.0
|
|
MELDEZEIT_TAKT_S = 0.05
|
|
|
|
# Wie weit die Geräteuhr der von Home Assistant vorauslaufen darf, bevor der
|
|
# Zeitstempel verworfen wird. Etwas Luft für Drift, aber keine Stunden - eine
|
|
# falsch gestellte Geräteuhr darf die Auswertung nicht mitreißen.
|
|
MELDEZEIT_VORLAUF = datetime.timedelta(minutes=5)
|
|
|
|
|
|
async def geraetezeit(
|
|
hass: HomeAssistant,
|
|
sensor: str | None,
|
|
ereigniszeit: datetime.datetime | None,
|
|
standard: datetime.datetime,
|
|
) -> datetime.datetime:
|
|
"""Der Zeitstempel, den das Gerät seinem Datensatz mitgegeben hat - oder
|
|
`standard`, wenn er fehlt oder unglaubwürdig ist.
|
|
|
|
Warum das nötig ist: `standard` ist die Uhrzeit, zu der Home Assistant den
|
|
Zustandswechsel verarbeitet hat. Hat das Gerät gepuffert - Funkloch,
|
|
Tiefgarage, Tiefschlaf -, liegt die Wahrheit beliebig weit davor. Am
|
|
01.09.2026 gemessen: ein Datensatz mit Fahrtende trug die Gerätezeit
|
|
07:29:04 und kam um 07:35:23 an, sechs Minuten später.
|
|
|
|
Der Wert ist eine Unix-Sekunde in UTC, nicht Ortszeit - ebenfalls
|
|
nachgemessen: der Versatz zur UTC-Uhr von Home Assistant lag bei Sekunden,
|
|
nicht bei zwei Stunden. Eine Zeitzonenumrechnung wäre nicht nur unnötig,
|
|
sie baute einen Zwei-Stunden-Fehler ein.
|
|
|
|
Gemeinsam für Fahrterkennung und Tankerkennung: beide legen Datensätze mit
|
|
einem Zeitstempel an und dürfen ihn nie unterschiedlich bestimmen - dieselbe
|
|
Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S."""
|
|
if not sensor:
|
|
return standard
|
|
|
|
if ereigniszeit is not None:
|
|
wartezeit = 0.0
|
|
while wartezeit < MELDEZEIT_FRIST_S:
|
|
zustand = hass.states.get(sensor)
|
|
if zustand is not None and zustand.last_updated >= ereigniszeit:
|
|
break
|
|
await asyncio.sleep(MELDEZEIT_TAKT_S)
|
|
wartezeit += MELDEZEIT_TAKT_S
|
|
|
|
zustand = hass.states.get(sensor)
|
|
try:
|
|
gemeldet = datetime.datetime.fromtimestamp(
|
|
float(zustand.state), datetime.UTC # type: ignore[union-attr]
|
|
)
|
|
except (AttributeError, TypeError, ValueError, OSError, OverflowError):
|
|
return standard
|
|
|
|
if gemeldet > standard + MELDEZEIT_VORLAUF:
|
|
_LOGGER.warning(
|
|
"Meldezeit %s liegt in der Zukunft (jetzt %s) - verworfen",
|
|
gemeldet.isoformat(), standard.isoformat(),
|
|
)
|
|
return standard
|
|
return gemeldet
|
|
|
|
|
|
def wert_bei(
|
|
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
|
) -> float | None:
|
|
"""Der zuletzt vor `zeitpunkt` gemeldete Zahlenwert, sonst der erste
|
|
danach, sonst None.
|
|
|
|
"Zuletzt davor" ist die richtige Wahl für einen Zählerstand: der
|
|
Kilometerstand bei Fahrtbeginn ist der, der zuletzt gemeldet wurde, nicht
|
|
der nächste (der schon Strecke enthält)."""
|
|
davor = None
|
|
for ts, wert in verlauf:
|
|
gezahlt = zahl(wert)
|
|
if gezahlt is None:
|
|
continue
|
|
if ts <= zeitpunkt:
|
|
davor = gezahlt
|
|
else:
|
|
return davor if davor is not None else gezahlt
|
|
return davor
|
|
|
|
|
|
def wert_ab(
|
|
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
|
|
) -> float | None:
|
|
"""Der erste ab `zeitpunkt` gemeldete Zahlenwert, sonst der letzte davor.
|
|
|
|
Gegenstueck zu wert_bei(). Fuer einen ZAEHLER ist -zuletzt davor- richtig
|
|
(der Kilometerstand aendert sich im Stand nicht), fuer einen gemessenen
|
|
FUELLSTAND nicht: der driftet.
|
|
|
|
Am 01.09.2026 an der Fahrt Eichstaett - Adelschlag nachgemessen. Der
|
|
Tankstand stand am Ende der vorigen Fahrt (13:52) auf 36,4 l, beim ersten
|
|
Datensatz der neuen Fahrt (16:20:49) auf 36,2 l - in der Standzeit dazwischen
|
|
ist niemand gefahren. Diese 0,2 l Setzung wurden der neuen Fahrt zugerechnet:
|
|
0,8 l statt 0,6 l auf 6,9 km, also **11,4 statt 8,7 l/100 km**.
|
|
|
|
Das Fahrtende bleibt bei wert_bei(): dort ist die Fahrt vorbei, und der
|
|
letzte waehrend der Fahrt gemeldete Stand ist der richtige - der naechste
|
|
kaeme erst Stunden spaeter und traege dieselbe Setzung wieder herein."""
|
|
danach = None
|
|
for ts, wert in verlauf:
|
|
gezahlt = zahl(wert)
|
|
if gezahlt is None:
|
|
continue
|
|
if ts >= zeitpunkt:
|
|
danach = gezahlt
|
|
break
|
|
if danach is not None:
|
|
return danach
|
|
return wert_bei(verlauf, zeitpunkt)
|
|
|
|
|
|
def route_aus_verlauf(
|
|
lat_verlauf: list[Verlaufspunkt],
|
|
lon_verlauf: list[Verlaufspunkt],
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> list[list[float]] | None:
|
|
"""Baut eine chronologische Liste von [lat, lon]-Punkten für die
|
|
Fahrt-Detailkarte, aus zwei getrennt geführten Sensor-Verläufen (die
|
|
FMM003 liefert Breiten-/Längengrad als zwei unabhängige Sensoren, siehe
|
|
STANDORT_LAT_SENSOR/STANDORT_LON_SENSOR). Jeder Breitengrad-Punkt im
|
|
Fenster [start, ende] bekommt den zeitlich nächstgelegenen Längengrad-Wert
|
|
zugeordnet - beide Sensoren aktualisieren unabhängig voneinander, exakt
|
|
gleiche Zeitstempel sind nicht garantiert. None bei weniger als zwei
|
|
Punkten (keine sinnvolle Linie)."""
|
|
punkte: list[list[float]] = []
|
|
for ts, wert in lat_verlauf:
|
|
if not (start <= ts <= ende):
|
|
continue
|
|
lat = zahl(wert)
|
|
if lat is None:
|
|
continue
|
|
lon = naechster_wert(ts, lon_verlauf)
|
|
if lon is None:
|
|
continue
|
|
punkte.append([lat, lon])
|
|
return punkte if len(punkte) >= 2 else None
|
|
|
|
|
|
# Wie weit die GNSS-Strecke vom Kilometerstand des Fahrzeugs abweichen darf.
|
|
#
|
|
# Beide Enden des Kilometerstands sind auf ganze Kilometer gerundet (am
|
|
# echten Fahrzeug nachgemessen: 147 Schritte von exakt 1,0 km, kein einziger
|
|
# feinerer). Die wahre Strecke liegt damit irgendwo in einem Fenster von
|
|
# +/- 1 km um die Differenz. Was darin liegt, ist mit dem Fahrzeugwert
|
|
# vereinbar; was darueber hinausgeht, ist es nicht - dann stimmt etwas nicht,
|
|
# und der Fahrzeugwert behaelt recht.
|
|
GNSS_TOLERANZ_KM = 1.0
|
|
|
|
|
|
def zaehlerstrecke(
|
|
punkte: list[Verlaufspunkt],
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> float | None:
|
|
"""Die im Fenster [start, ende] gefahrene Strecke aus einem laufenden
|
|
Kilometerzaehler.
|
|
|
|
Der Anker ist der letzte Datensatz AM ODER VOR `start`, nicht der erste
|
|
danach: der Zaehlerstand zu Fahrtbeginn ist der zuletzt gemeldete - dieselbe
|
|
Ueberlegung wie bei wert_bei(). Ohne ihn fehlt die Strecke zwischen dem
|
|
Beginn und dem ersten Datensatz danach.
|
|
|
|
WARUM DIESE FUNKTION EXISTIERT: Livepfad und Rueckblick haben dieselbe
|
|
Fahrt unterschiedlich gemessen. Das Screening las den Verlauf ueber
|
|
verlauf_lesen(), das den Stand ZU BEGINN des Fensters mitliefert; der
|
|
Rueckblick schnitt sein Fenster streng heraus und verlor genau diesen
|
|
Punkt. Am 01.09.2026 an der echten Fahrt nachgemessen: 6,896 km gegen
|
|
6,816 km fuer denselben Zeitraum. Das ist die Doppelbewertung, die dieses
|
|
Projekt bei UNPLAUSIBLE_KMH und MINDESTDAUER_S schon zweimal ausgeraeumt
|
|
hat - jetzt schneiden beide Wege durch dieselbe Funktion."""
|
|
davor = [p for p in punkte if p[0] <= start]
|
|
im_fenster = [p for p in punkte if start < p[0] <= ende]
|
|
return strecke_aus_zaehler((davor[-1:] if davor else []) + im_fenster)
|
|
|
|
|
|
def strecke_waehlen(
|
|
grob: float | None, fein: float | None
|
|
) -> tuple[float | None, bool]:
|
|
"""Welche der beiden Streckenangaben gilt - und wurde die feine genommen?
|
|
|
|
`grob` ist die Differenz der Fahrzeug-Kilometerstände: der Anker. Er ist
|
|
der Tacho, er driftet nicht, und er ist die Zahl im Cockpit - aber er löst
|
|
nur ganze Kilometer auf. `fein` ist die Strecke aus dem GNSS-Zähler des
|
|
Geräts: metergenau, dafür anfällig für fehlenden Empfang und Drift.
|
|
|
|
Die feine gilt, solange sie innerhalb der Rundungsunschärfe des Ankers
|
|
liegt (GNSS_TOLERANZ_KM) - oder wenn es gar keinen Anker gibt, denn dann
|
|
ist irgendeine Messung besser als keine. Liegt sie darüber, hat der Anker
|
|
recht: dann fehlte Empfang oder der Zähler ist gedriftet.
|
|
|
|
Gemeinsam für Livepfad (screening.py) und Rückblick (historienimport.py) -
|
|
dieselbe Regel wie bei UNPLAUSIBLE_KMH und MINDESTDAUER_S: beide Wege
|
|
dürfen dieselbe Fahrt nie unterschiedlich bewerten."""
|
|
if fein is None:
|
|
return grob, False
|
|
if grob is None or abs(fein - grob) <= GNSS_TOLERANZ_KM:
|
|
return fein, True
|
|
return grob, False
|
|
|
|
|
|
def ist_gefahren(distanz: float | None) -> bool:
|
|
"""Hat sich das Fahrzeug bewegt?
|
|
|
|
Auf eine Nachkommastelle gerundet: ab 0,1 km ist es eine Fahrt, darunter
|
|
nicht (Ansage des Eigentümers, 01.09.2026). Unter 50 Metern zeigt die
|
|
Oberfläche ohnehin \"0,0 km\" an, und der Eintrag sähe aus wie die
|
|
Nullfahrten, die aus dem Bestand geräumt wurden.
|
|
|
|
Eine UNBEKANNTE Strecke gilt als gefahren: liegt weder Kilometerstand noch
|
|
GNSS-Zähler vor, wissen wir es nicht - und eine Fahrt zu verwerfen, die man
|
|
nicht gemessen hat, wäre schlechter als eine ohne Streckenangabe zu
|
|
behalten. Nur die gemessene Null ist ein Nein."""
|
|
return distanz is None or round(distanz, 1) != 0
|
|
|
|
|
|
def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
|
|
"""Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.
|
|
|
|
Summiert die Zuwaechse statt einfach Ende minus Anfang zu rechnen, weil der
|
|
Zaehler zurueckspringen kann. Ein Ruecksprung ist kein Rueckwaertsfahren,
|
|
sondern eine Ruecksetzung - der neue Wert ist dann selbst der Zuwachs seit
|
|
der Ruecksetzung, alles davor wurde bereits gezaehlt.
|
|
|
|
Am 01.09.2026 an zwei echten Fahrten gemessen:
|
|
|
|
* Fahrt 1, mit Ruecksetzung mitten in der Fahrt (das Geraet uebernahm
|
|
seinen konfigurierten Startwert 0, als der CAN-Kilometerstand zum ersten
|
|
Mal hochzaehlte): 0,415 km vor der Ruecksetzung, 6,543 km danach - macht
|
|
6,958 km. Google Maps sagt 7,1 km fuer dieselbe Strecke. Ohne die
|
|
Ruecksprungbehandlung waeren es 6,543 km gewesen, also 0,4 km zu wenig.
|
|
* Fahrt 2, ohne Ruecksetzung: 6,816 km. Dort degeneriert die Summe zu
|
|
Ende minus Anfang, wie es sein soll.
|
|
|
|
Bewusst NICHT ueber die Teilstrecken je Datensatz (segment_mileage), die
|
|
dasselbe Geraet mitliefert: die verlieren einzelne Datensaetze. In Fahrt 2
|
|
waren 4 von 50 Teilstrecken null, obwohl gefahren wurde, und die Summe lag
|
|
dadurch 0,2 km zu niedrig. Der Zaehler zaehlt ueber solche Aussetzer hinweg.
|
|
|
|
None, wenn weniger als zwei Zahlenwerte vorliegen."""
|
|
werte = [zahl(wert) for _ts, wert in punkte]
|
|
werte = [w for w in werte if w is not None]
|
|
if len(werte) < 2:
|
|
return None
|
|
strecke = 0.0
|
|
for vorher, jetzt in zip(werte, werte[1:]):
|
|
strecke += jetzt if jetzt < vorher else jetzt - vorher
|
|
return round(strecke, 3)
|
|
|
|
|
|
def hoechstwert_im_fenster(
|
|
verlauf: list[Verlaufspunkt],
|
|
start: datetime.datetime,
|
|
ende: datetime.datetime,
|
|
) -> float | None:
|
|
"""Der größte Zahlenwert eines Verlaufs innerhalb von [start, ende].
|
|
|
|
Für die Höchstgeschwindigkeit einer Fahrt. Das ist der größte
|
|
GEMELDETE Wert, nicht die tatsächliche Spitze: der FMM003 schreibt
|
|
während der Fahrt alle zehn Sekunden einen Datensatz (Data
|
|
Acquisition -> Moving -> "Min Period" = 10 s), eine kurze Spitze
|
|
dazwischen sieht niemand. Für eine Fahrtenübersicht ist das genau
|
|
genug; als Beweismittel taugt es nicht, und so ist es auch
|
|
beschriftet.
|
|
|
|
None, wenn im Fenster kein einziger Zahlenwert liegt."""
|
|
werte = [
|
|
float(wert)
|
|
for ts, wert in verlauf
|
|
if start <= ts <= ende and _als_zahl(wert) is not None
|
|
]
|
|
return round(max(werte), 1) if werte else None
|
|
|
|
|
|
def _als_zahl(wert: object) -> float | None:
|
|
"""Ein Verlaufswert als Zahl, oder None - Zustände wie "unavailable"
|
|
kommen hier genauso an wie Messwerte."""
|
|
try:
|
|
return float(wert) # type: ignore[arg-type]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
# Unterhalb dieser Strecke sagt die Literdifferenz nichts mehr: der
|
|
# Tanksensor löst 0,1 l auf (an der echten Aufzeichnung nachgemessen,
|
|
# 2026-08-31), ein Quantisierungsschritt entspricht also 10/Strecke
|
|
# l/100 km Fehler - bei 3 km sind das ±3,3, bei 1 km schon ±10.
|
|
MIN_STRECKE_VERBRAUCH_KM = 3.0
|
|
|
|
# Ein Verbrennerwert darüber ist kein Verbrauch, sondern ein Messfehler
|
|
# (Sensorsprung, Tankvorgang kurz vor Fahrtende, Betankung im Stand).
|
|
UNPLAUSIBLE_L_100KM = 60.0
|
|
|
|
|
|
def verbrauch_aus_literstaenden(
|
|
start_liter: float | None, end_liter: float | None, distance_km: float | None
|
|
) -> float | None:
|
|
"""l/100km aus der Differenz zweier Literstände (TANK_LITER_SENSOR) und
|
|
der gefahrenen Strecke - eine Näherung, kein vom Fahrzeug selbst für
|
|
diese eine Fahrt gemeldeter Wert (ein solcher OBD-Sensor steht als
|
|
offener Punkt in AGENTS.md; taucht einer auf, ersetzt er diese Näherung).
|
|
|
|
None, wenn einer der Werte fehlt, die Strecke nicht positiv ist, oder der
|
|
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
|
|
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
|
|
aussagekräftig."""
|
|
if start_liter is None or end_liter is None or not distance_km:
|
|
return None
|
|
if distance_km < MIN_STRECKE_VERBRAUCH_KM:
|
|
return None
|
|
verbraucht = start_liter - end_liter
|
|
if verbraucht <= 0:
|
|
return None
|
|
wert = round(verbraucht / distance_km * 100, 1)
|
|
return wert if wert <= UNPLAUSIBLE_L_100KM else None
|
|
|
|
|
|
def naechster_wert(
|
|
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
|
|
) -> float | None:
|
|
"""Der Wert, dessen Zeitstempel am nächsten an `zielzeit` liegt.
|
|
|
|
Anders als wert_bei() ist hier bewusst egal, ob der Wert davor oder danach
|
|
liegt: der Kilometerstand kommt laut Datenquelle nicht sicher mit
|
|
Fahrtende, sondern teils erst mit Beginn der nächsten Fahrt."""
|
|
bester_wert = None
|
|
beste_diff = None
|
|
for ts, wert in verlauf:
|
|
gezahlt = zahl(wert)
|
|
if gezahlt is None:
|
|
continue
|
|
diff = abs((ts - zielzeit).total_seconds())
|
|
if beste_diff is None or diff < beste_diff:
|
|
bester_wert = gezahlt
|
|
beste_diff = diff
|
|
return bester_wert
|
|
|
|
|
|
# Wie lange eine Nebenmessung (z. B. Außentemperatur) höchstens von der
|
|
# Hauptmessung entfernt liegen darf, um noch als "zur selben Zeit" zu gelten -
|
|
# vom Nutzer auf 300s (5 Minuten) festgelegt, nach ursprünglich 60s. Der
|
|
# Wechsel ist an echten FMM003-Daten begründet, nicht geschätzt: das Gerät
|
|
# sendet Außentemperatur deutlich seltener als Spannung (an einem
|
|
# beobachteten Fahrtfenster 851 Spannungs- gegen 187 Temperaturmeldungen in
|
|
# 48h) - selbst bei laufender Fahrt und ohne jede Verbindungsstörung wurden
|
|
# reale Lücken bis 593s zwischen zwei Temperaturmeldungen gemessen. 60s hätte
|
|
# genau diesen völlig normalen Fall verworfen. Physikalisch unproblematisch:
|
|
# die Außentemperatur ändert sich über wenige Minuten kaum, anders als z. B.
|
|
# eine GPS-Position oder die Spannung selbst - und die Live-Prüfung
|
|
# (batterie.py's pruefen()) läuft ohnehin nur alle 5 Minuten, verlangt hier
|
|
# also keine feinere Genauigkeit, als sie selbst liefert. Ein Verbindungs-
|
|
# abbruch des Geräts (beobachtet: 45 Minuten, beide Sensoren gleichzeitig
|
|
# stumm) bleibt mit 300s weiterhin sicher außerhalb der Grenze und liefert
|
|
# bewusst KEINEN Wert statt eines beliebig alten.
|
|
NEBENWERT_MAX_ABSTAND_S = 300
|
|
|
|
|
|
def wert_in_naehe(
|
|
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt], max_abstand_s: float = NEBENWERT_MAX_ABSTAND_S
|
|
) -> float | None:
|
|
"""Wie naechster_wert(), aber None statt eines beliebig weit entfernten
|
|
Werts, wenn selbst der zeitlich nächste mehr als max_abstand_s von
|
|
zielzeit entfernt liegt."""
|
|
bester_wert = None
|
|
beste_diff = None
|
|
for ts, wert in verlauf:
|
|
gezahlt = zahl(wert)
|
|
if gezahlt is None:
|
|
continue
|
|
diff = abs((ts - zielzeit).total_seconds())
|
|
if beste_diff is None or diff < beste_diff:
|
|
bester_wert = gezahlt
|
|
beste_diff = diff
|
|
if beste_diff is None or beste_diff > max_abstand_s:
|
|
return None
|
|
return bester_wert
|