Fahrten: Verbrauch-Näherung, 0V-Zustandsanzeige gefiltert, Plausibilitätsgrenze für die Live-Vervollständigung

Verbrauch (l/100km) war bislang totes Schema - kein Codepfad füllte
verbrauch_l_100km je. Jetzt aus der Literstand-Differenz (TANK_LITER_SENSOR)
über die Distanz genähert, live und beim Import, in beiden Frontends.

Die "Zustand"-Kachel zeigte die Batteriespannung ungefiltert direkt vom
Sensor, unabhängig von der Plausibilitätsgrenze der Verlaufsaufzeichnung -
ein Sensorausreißer (0V) zeigte sich dort weiterhin, obwohl die Messwertliste
ihn längst verwarf. Dieselbe Grenze gilt jetzt auch für diesen Anzeigepfad.

Reale Fahrtendaten zeigten eine Fahrt mit 22km in 67s (~1180 km/h) - die
Live-Vervollständigung (screening.py) hatte anders als der Import keine
Plausibilitätsprüfung der Durchschnittsgeschwindigkeit. Jetzt gemeinsam in
verlauf.py (UNPLAUSIBLE_KMH/durchschnitt_kmh) für beide Pfade. Dabei einen
zweiten echten Bug gefunden: _fahrt_screenen() zog sein Ergebnis nie ins
In-Memory-Objekt nach (nur in die Ablage) - eine im selben Durchlauf gerade
erst ermittelte Distanz blieb für spätere Schritte (z. B. Verbrauch) bis zum
nächsten Screening unsichtbar. Beide Stellen jetzt behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:01:57 +02:00
parent cf57c5f1a5
commit 80d73e8afd
12 changed files with 344 additions and 30 deletions
@@ -52,6 +52,7 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"verbrauch_l_100km": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
@@ -1 +1 @@
{"version":"2026.8.27.5","sha256":"8d63b052bdcf4cf2eabd5a01f525fc25c4ab8eea30327dc3b8cbd0db2feb05ed","bytes":235510,"gebaut":"2026-08-27T16:30:48Z"}
{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"}
@@ -3165,7 +3165,7 @@ function vTrip(id) {
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
</div>
@@ -44,7 +44,16 @@ from typing import TYPE_CHECKING
from .batterie import SPANNUNG_MIN_V
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import Verlaufspunkt, route_aus_verlauf, verlauf_lesen, wert_bei, zahl
from .verlauf import (
UNPLAUSIBLE_KMH,
Verlaufspunkt,
durchschnitt_kmh,
route_aus_verlauf,
verbrauch_aus_literstaenden,
verlauf_lesen,
wert_bei,
zahl,
)
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -61,14 +70,11 @@ TANK_DUBLETTE_MIN = 90
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
# 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()-Treffer über eine Lücke hinweg). Die Strecke wird dann verworfen
# statt eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt
# "offen", genau wie bei fehlendem Kilometerstand.
UNPLAUSIBLE_KMH = 300
# UNPLAUSIBLE_KMH selbst lebt in verlauf.py - gemeinsam mit screening.py, das
# dieselbe Prüfung seit 2026.8.27 auch für die Live-Vervollständigung nutzt
# (siehe dortiges _vollstaendig()). Die Strecke wird dann verworfen statt
# eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt "offen",
# genau wie bei fehlendem Kilometerstand.
def als_zeit(wert: object) -> datetime.datetime | None:
@@ -150,6 +156,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
tank_liter_verlauf = verlaeufe["tank_liter"]
angelegt = uebersprungen = zu_kurz = 0
neue: list[dict] = []
@@ -169,19 +176,17 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
if odo_start is not None and odo_end is not None and odo_end >= odo_start:
distanz = round(odo_end - odo_start, 1)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
if durchschnitt > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
)
distanz = None
odo_start = odo_end = None
durchschnitt = None
durchschnitt = durchschnitt_kmh(distanz, dauer_s)
if durchschnitt is not None and durchschnitt > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
)
distanz = None
odo_start = odo_end = None
durchschnitt = None
fahrt = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
@@ -195,6 +200,9 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"end_lat": wert_bei(lat_verlauf, f_ende),
"end_lon": wert_bei(lon_verlauf, f_ende),
"route": route_aus_verlauf(lat_verlauf, lon_verlauf, f_start, f_ende),
"verbrauch_l_100km": verbrauch_aus_literstaenden(
wert_bei(tank_liter_verlauf, f_start), wert_bei(tank_liter_verlauf, f_ende), distanz
),
"status": "vollständig" if distanz is not None else "offen",
})
neue.append(fahrt)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.27.5",
"version": "2026.8.27.8",
"documentation": "https://gitea.nothaft.cloud/paul/audi-app/src/branch/main/README.md",
"issue_tracker": "https://gitea.nothaft.cloud/paul/audi-app/issues",
"codeowners": ["@paul"],
+72 -4
View File
@@ -22,7 +22,14 @@ import datetime
import logging
from typing import TYPE_CHECKING
from .verlauf import naechster_wert, route_aus_verlauf, verlauf_lesen
from .verlauf import (
UNPLAUSIBLE_KMH,
durchschnitt_kmh,
naechster_wert,
route_aus_verlauf,
verbrauch_aus_literstaenden,
verlauf_lesen,
)
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -47,9 +54,30 @@ def _als_zeit(wert: str | None) -> datetime.datetime | None:
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch".
Verwirft eine physikalisch unplausible Durchschnittsgeschwindigkeit
(UNPLAUSIBLE_KMH) statt sie anzuzeigen, statt der bislang unbehandelten
Lücke gegenüber historienimport.py: eine falsch abgegrenzte Fahrt (z. B.
durch ein chatterndes Zündungssignal) kann sonst einen Kilometerstand-
Sprung wie "22 km in 67 s" als echte Strecke zeigen. Sowohl odo_start als
auch odo_end werden dann verworfen (nicht nur die Strecke) - die Fahrt
bleibt "offen" und wird beim nächsten Screening erneut versucht, statt
einen unmöglichen Wert dauerhaft zu speichern."""
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
distanz = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
geschwindigkeit = durchschnitt_kmh(distanz, fahrt.get("duration_s"))
if geschwindigkeit is not None and geschwindigkeit > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Fahrt %s: Kilometerstand verworfen - unmögliche %s km/h im "
"Schnitt (%s km in %s s), vermutlich ein Ausreißer im "
"Verlauf oder eine falsch abgegrenzte Fahrt.",
fahrt.get("trip_id"), geschwindigkeit, distanz, fahrt.get("duration_s"),
)
aenderungen["odo_start"] = None
aenderungen["odo_end"] = None
return aenderungen
aenderungen["distance_km"] = distanz
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
return aenderungen
@@ -81,6 +109,15 @@ async def durchfuehren(k: Koordinator) -> None:
for fahrt in ohne_position:
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
if tank_liter_sensor:
ohne_verbrauch = [
f for f in fahrten
if f.get("verbrauch_l_100km") is None and f.get("distance_km")
]
for fahrt in ohne_verbrauch:
await _verbrauch_screenen(k, tank_liter_sensor, fahrt)
await _lueckenlos_verketten(k)
await k.fahrten_veroeffentlichen()
@@ -115,7 +152,15 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
if geaendert:
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
aenderungen = _vollstaendig(aenderungen, fahrt)
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
# Das In-Memory-Objekt sofort mitziehen (nicht nur die Ablage) - sonst
# sehen spätere Schritte innerhalb desselben durchfuehren()-Laufs
# (z. B. _verbrauch_screenen(), das eine bekannte distance_km
# braucht) noch den alten Stand, obwohl die Strecke gerade erst in
# diesem Durchlauf ermittelt wurde. _position_screenen() macht das
# bereits genauso.
fahrt.update(aenderungen)
async def _position_screenen(
@@ -161,6 +206,29 @@ async def _position_screenen(
fahrt.update(aenderungen)
async def _verbrauch_screenen(
k: Koordinator, tank_liter_sensor: str, fahrt: dict
) -> None:
"""Trägt den Verbrauch (l/100 km) nach, sobald die Distanz feststeht und
der Tankfüllstand (Liter) das hergibt - siehe verbrauch_aus_literstaenden()
für die Näherung selbst und ihre Begrenzung."""
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
if start is None or ende is None:
return
verlauf = await verlauf_lesen(k.hass, tank_liter_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
if not verlauf:
return
verbrauch = verbrauch_aus_literstaenden(
naechster_wert(start, verlauf), naechster_wert(ende, verlauf), fahrt.get("distance_km")
)
if verbrauch is not None:
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"verbrauch_l_100km": verbrauch})
fahrt["verbrauch_l_100km"] = verbrauch
async def _lueckenlos_verketten(k: Koordinator) -> None:
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde.
@@ -85,6 +85,25 @@ def zahl(wert: object) -> float | None:
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
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)
def wert_bei(
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
) -> float | None:
@@ -134,6 +153,26 @@ def route_aus_verlauf(
return punkte if len(punkte) >= 2 else None
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 or distance_km <= 0:
return None
verbraucht = start_liter - end_liter
if verbraucht <= 0:
return None
return round(verbraucht / distance_km * 100, 1)
def naechster_wert(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
) -> float | None:
@@ -149,11 +149,25 @@ def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
standort = _standort(hass, werte)
# Verzögerter Import (statt oben am Dateikopf): batterie.py importiert
# seinerseits zustand_oder_none aus diesem Modul - ein Import auf
# Modulebene würde bei bestimmter Ladereihenfolge zu einem
# Circular-Import-Fehler führen. Zum Zeitpunkt dieses Funktionsaufrufs
# sind beide Module immer schon vollständig geladen.
from .batterie import SPANNUNG_MIN_V
spannung = _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR))
if spannung is not None and spannung < SPANNUNG_MIN_V:
# Dieselbe Plausibilitätsgrenze wie beim Aufzeichnen (batterie.py) -
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
# nie auftaucht, weil er dort schon verworfen wird.
spannung = None
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
"batteriespannung": spannung,
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die