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:
@@ -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"}
|
||||
Binary file not shown.
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user