Files
tobias 2af5c469d6 Ortsnamen aus dem Backend, Verbrauchsfaktor aus dem Tankbeleg, Radzaehler ueber Fahrten
Ortsauflösung serverseitig (geokodierung.py): der Ort steht jetzt in der Fahrt
statt im Zwischenspeicher jedes Geräts. Beide Oberflächen fragen nichts mehr ab.

Verbrauchskorrektur (verbrauchskorrektur.py): Faktor = Beleg-Liter / Summe der
Einzelfahrten, voll zu voll. Statt einer Schranke am Faktor wird der Nenner
geprüft - decken die erkannten Fahrten 90-110 % der Tacho-Spanne ab? Ausgelöst
beim Schreiben eines Tankvorgangs, nicht erst bei der nächsten Fahrt.

Radzähler: summiert die Fahrten des montierten Satzes statt Tacho-Deltas. Der
alte Weg hatte Fahrzeugwechsel als gefahrene Strecke verbucht (375.962 km bei
einem Tacho von 61.823).

Null heißt unbekannt: ein Kilometerstand von 0 oder ein leeres Feld werden zu
None normalisiert, statt an sechs Lesestellen als "Tachostand null" zu gelten.
Momentanwerte der Sensoren gelten nur für einen Tankvorgang von jetzt.

Oberfläche, beide Codebasen: Wertespalte der Fahrtenliste ausgerichtet, Ort ->
Ort in "Zuletzt", "Räder" statt "Reifen", "Termin vereinbart" entfernt,
Markenlogo 20 % größer, Übersicht-Symbol auf volle Rasterbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:28:37 +02:00

369 lines
16 KiB
Python

"""Der Verbrauch einer Fahrt, korrigiert an den Tankbelegen.
DAS PROBLEM. Der Verbrauch einer einzelnen Fahrt entsteht aus der Differenz
zweier Tankstaende. Der Geber liefert zwar Liter (das Steuergeraet hat seine
Kennlinie schon angewandt), aber er rundet, und er ist stark gedaempft - sonst
zappelte die Anzeige im Cockpit bei jeder Kurve. Auf einer Fahrt von sieben
Kilometern faellt dieser Verzug anteilig weit staerker ins Gewicht als auf
dreihundert.
DIE RECHNUNG, so wie der Eigentuemer sie ohnehin von Hand macht:
Faktor = getankte Liter laut Beleg / Summe der Liter aus den Einzelfahrten
Die Annahme dahinter ist seine: die Ungenauigkeit des Gebers trifft jede Fahrt
gleichermassen, ist also ein Faktor und kein Summand. Beide Seiten decken
denselben Zeitraum ab; was der Faktor korrigiert, ist genau der Unterschied
zwischen dem, was der Geber gemeldet hat, und dem, was wirklich in den Tank
gelaufen ist.
WARUM NUR VON VOLLTANKUNG ZU VOLLTANKUNG. Nur dann gilt "eingefuellte Liter =
seit dem letzten Mal verbrauchte Liter": eine Betankung, die den Tank wieder
bis zum selben Punkt fuellt, ersetzt genau das, was seither entnommen wurde.
Nach einer Teilbetankung waere der Zaehler eine beliebige Zahl und der Faktor
falsch. Als voll gilt ein Tankvorgang, wenn der Stand danach mindestens
VOLL_ANTEIL des Tankvolumens erreicht (aus dem Fahrzeugprofil).
DIE LITER MUESSEN AUS DEM BELEG STAMMEN, nicht aus dem Geber. Ein automatisch
erkannter Tankvorgang traegt in `liters` den vom Geber gemessenen Anstieg -
den gegen die ebenfalls vom Geber gerechneten Fahrten zu stellen, waere ein
Zirkelschluss und ergaebe immer ungefaehr 1,0. Gezaehlt wird deshalb nur, was
aus einem Beleg geparst oder von Hand eingetragen wurde.
STANDLAUF IM STILLSTAND stoert die Rechnung nicht: laeuft der Motor bei
eingeschalteter Zuendung, bleibt die Fahrt offen, der Verbrauch steht also in
der Summe der Einzelfahrten UND in der getankten Menge und kuerzt sich heraus.
Die betroffene Fahrt zeigt dann zu Recht einen hohen Verbrauch. Verzerrend ist
nur Verbrauch ohne offene Fahrt - Standheizung bei Zuendung aus; der fehlt im
Nenner und verteilt sich ueber den Faktor auf alle Fahrten des Zeitraums. Der
Eigentuemer hat das am 01.09.2026 ausdruecklich so gewollt.
WAS GEPRUEFT WIRD, UND WARUM NICHT DER FAKTOR SELBST. Die beiden Tore oben
sichern den ZAEHLER: die Liter stammen aus einem Beleg, und voll zu voll
heisst, dass sie dem Verbrauch entsprechen. Verwundbar bleibt der NENNER -
fehlt im Intervall eine Fahrt (Dongle offline, HA unten, Erkennung
ausgesetzt), ist die Summe zu klein und der Faktor zu gross. Er multipliziert
dann jede Fahrt des Zeitraums mit.
Eine feste Ober- und Untergrenze am Faktor (erst 0,7 bis 1,5) traf das
Symptom, nicht die Ursache - und haette einen echten, groesseren Geberfehler
mit abgeklemmt. Geprueft wird deshalb, ob der Nenner ueberhaupt vollstaendig
ist: die Summe der Fahrtstrecken gegen die Tacho-Spanne desselben Intervalls.
Deckung = Summe der Fahrtstrecken / (Kilometerstand bis - Kilometerstand von)
Das Mass ist belegt: beim Umbau des Radzaehlers am 01.09.2026 lag es in den
sauberen Bereichen bei 99 bis 103 %. Liegt es dort, ist der Faktor
vertrauenswuerdig - egal wie er ausfaellt, ohne obere oder untere Klemme.
Liegt es daneben, beschreibt der Faktor fehlende Daten und nicht den Geber,
und das Intervall wird uebersprungen und protokolliert.
WAS GESPEICHERT WIRD. verbrauch_roh_l_100km ist die reine Messung und bleibt
unangetastet, damit die Rechnung wiederholbar ist. verbrauch_faktor ist der
angewandte Faktor. verbrauch_l_100km ist das Produkt aus beiden - der Wert,
den die Oberflaechen anzeigen; die brauchen von alldem nichts zu wissen.
"""
from __future__ import annotations
import datetime
import logging
from typing import TYPE_CHECKING
from .tankerkennung import STANDARD_TANKVOLUMEN_LITER
from .verlauf import verlauf_lesen, wert_bei
if TYPE_CHECKING:
from .koordinator import Koordinator
_LOGGER = logging.getLogger(__name__)
# Wie vollstaendig die erkannten Fahrten die tatsaechlich gefahrene Strecke
# des Intervalls abdecken muessen. Der Wert sollte per Definition bei 1,0
# liegen; eine Abweichung in BEIDE Richtungen heisst, dass die Fahrtmenge
# nicht zur Wirklichkeit passt - zu wenig (fehlende Fahrten) ebenso wie zu
# viel (doppelte oder falsch vermessene Fahrten, im Altbestand real
# vorgekommen: 256 %).
DECKUNG_MIN = 0.90
DECKUNG_MAX = 1.10
# Wie weit der Kilometerstand eines Tankvorgangs vom Tacho zu seinem eigenen
# Zeitstempel abweichen darf. Der Tacho springt in ganzen Kilometern, und
# zwischen Zapfsaeule und dem Datensatz, den das Geraet danach schickt, liegen
# selten mehr als ein paar - siehe _datum_passt().
ODOMETER_TOLERANZ_KM = 5.0
# Ab welchem Anteil des Tankvolumens ein Tankvorgang als Volltankung gilt.
# 95 % laesst Luft fuer die Ungenauigkeit des Gebers und fuer den Umstand, dass
# "voll" an der Zapfsaeule nicht auf den Liter reproduzierbar ist.
VOLL_ANTEIL = 0.95
# Der gedaempfte Tankstand steht nach dem Tanken nicht sofort. Eine Minute ist
# die Vorgabe des Eigentuemers (01.09.2026).
#
# Zu kurz zu greifen ist hier die harmlose Richtung: liest die Pruefung einen
# noch nicht ganz nachgezogenen Stand, faellt er unter die 95-%-Grenze und das
# Intervall gilt als "nicht voll" - es entsteht dann kein Faktor. Zu lang zu
# warten waere die gefaehrliche Richtung, denn nach der Abfahrt sinkt der Stand
# wieder und eine echte Volltankung saehe nachtraeglich unvoll aus.
SETZZEIT = datetime.timedelta(minutes=1)
# Wie weit ueber die Intervallgrenzen hinaus der Verlauf geholt wird, damit
# wert_bei() links vom Fensterrand einen Anker findet.
PUFFER = datetime.timedelta(hours=6)
def _zeit(wert: str | None) -> datetime.datetime | None:
if not wert:
return None
try:
gelesen = datetime.datetime.fromisoformat(str(wert))
except ValueError:
return None
if gelesen.tzinfo is None:
gelesen = gelesen.replace(tzinfo=datetime.UTC)
return gelesen
def beleg_liter(tankvorgang: dict) -> float | None:
"""Die getankte Menge, aber nur wenn sie NICHT vom Geber stammt.
Ein automatisch erkannter Tankvorgang traegt in `liters` den gemessenen
Anstieg des Tankstands. Den gegen die ebenfalls aus dem Tankstand
gerechneten Fahrten zu stellen, ergaebe zwangslaeufig ungefaehr 1,0 - der
Faktor koennte gar nichts korrigieren.
"""
liter = tankvorgang.get("liters")
if liter is None:
return None
aus_beleg = bool(tankvorgang.get("receipt_key"))
von_hand = "liters" in (tankvorgang.get("edited_fields") or [])
manuell = tankvorgang.get("source") == "manual"
if not (aus_beleg or von_hand or manuell):
return None
try:
return float(liter)
except (TypeError, ValueError):
return None
def _liter_der_fahrt(fahrt: dict) -> float | None:
"""Was diese Fahrt laut Messung verbraucht hat, in Litern."""
roh = fahrt.get("verbrauch_roh_l_100km")
strecke = fahrt.get("distance_km")
if roh is None or not strecke or strecke <= 0:
return None
return float(roh) / 100.0 * float(strecke)
async def _tacho_spanne(
k: Koordinator,
km_sensor: str,
von: datetime.datetime,
bis: datetime.datetime,
) -> tuple[float, float] | None:
"""Die Tachostaende zu Beginn und Ende des Intervalls, oder None.
Zwei Dinge haengen daran: die Deckungspruefung (passt die Fahrtmenge zur
gefahrenen Strecke?) und die Datumspruefung (passt der eingetragene
Zeitpunkt eines Tankvorgangs zu seinem eigenen Kilometerstand?).
Der Kilometerstand ist der Tacho des Fahrzeugs: er zaehlt jeden Kilometer,
auch die einer nicht erkannten Fahrt. Genau deshalb taugt er als Mass
dafuer, ob die Fahrtmenge vollstaendig ist."""
verlauf = await verlauf_lesen(k.hass, km_sensor, von - PUFFER, bis + PUFFER)
if not verlauf:
return None
odo_von = wert_bei(verlauf, von)
odo_bis = wert_bei(verlauf, bis)
if odo_von is None or odo_bis is None:
return None
if odo_bis - odo_von <= 0:
# Kein Fortschritt oder ein Rueckwaertssprung (Fahrzeugwechsel) - der
# Tacho taugt hier nicht als Mass.
return None
return odo_von, odo_bis
def _datum_passt(tankvorgang: dict, tacho: float) -> bool:
"""Passt der eingetragene Zeitpunkt zum Kilometerstand des Tankvorgangs?
Die Fahrten werden ueber die ZEIT zugeordnet, nicht ueber den Tacho. Traegt
jemand die Tankung von Dienstag am Freitag mit "jetzt" ein, wandern die
Liter auf die Fahrten von Mittwoch bis Freitag - und die Deckungspruefung
merkt es nicht, weil sie den Tacho zu denselben verschobenen Zeitpunkten
liest: Zaehler und Nenner sind konsistent verschoben.
Der Tankvorgang traegt aber einen zweiten, unabhaengigen Anker: seinen
eigenen Kilometerstand. Weicht der stark vom Tacho zum eingetragenen
Zeitpunkt ab, passen Datum und Kilometerstand nicht zusammen.
Ist kein Kilometerstand hinterlegt (der Nutzer kannte ihn nicht, siehe
verlauf.als_kilometerstand()), gibt es nichts zu vergleichen - dann gilt
der Zeitpunkt, wie er eingetragen wurde. Ein automatisch erkannter
Tankvorgang besteht die Pruefung ohnehin immer: sein Kilometerstand und
sein Zeitstempel stammen aus demselben Datensatz."""
eigener = tankvorgang.get("odometer_km")
if eigener is None:
return True
return abs(float(eigener) - tacho) <= ODOMETER_TOLERANZ_KM
async def _ist_voll(k: Koordinator, tank_sensor: str, zeitpunkt: datetime.datetime,
volumen: float) -> bool:
"""Stand der Tank nach dieser Betankung (nahezu) voll?
Gemessen SETZZEIT nach dem Zeitpunkt des Tankvorgangs - siehe dort, warum
die Zahl lieber knapp als grosszuegig ist."""
verlauf = await verlauf_lesen(
k.hass, tank_sensor, zeitpunkt - PUFFER, zeitpunkt + SETZZEIT + PUFFER
)
if not verlauf:
return False
stand = wert_bei(verlauf, zeitpunkt + SETZZEIT)
return stand is not None and stand >= volumen * VOLL_ANTEIL
async def anwenden(k: Koordinator) -> None:
"""Rechnet jedes noch unbewertete Voll-zu-Voll-Intervall durch.
Bewusst nur die unbewerteten: das Screening laeuft bei jeder Aenderung des
Kilometerstands, und jedes Intervall braucht eigene Abfragen des
Tankverlaufs. Ohne diese Einschraenkung liefen bei dreizehn Betankungen
dieselben Abfragen bei jedem Fahrtende erneut.
"""
tank_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
if not tank_sensor:
return
km_sensor = k.zuordnung.werte.KM_SENSOR
if not km_sensor:
# Ohne Tacho laesst sich nicht pruefen, ob die Fahrtmenge vollstaendig
# ist - siehe Modulkopf. Lieber kein Faktor als ein ungepruefter.
_LOGGER.debug("Kein Kilometerstand-Sensor zugeordnet - keine Verbrauchskorrektur")
return
profil = await k.ablage.profil_lesen()
volumen = float(
(profil or {}).get("fahrzeug", {}).get("tankvolumen_liter")
or STANDARD_TANKVOLUMEN_LITER
)
tankvorgaenge = [t for t in await k.ablage.tankvorgaenge_lesen() if _zeit(t.get("ts"))]
if len(tankvorgaenge) < 2:
return
tankvorgaenge.sort(key=lambda t: t["ts"])
fahrten = await k.ablage.fahrten_lesen()
voll_bekannt: dict[str, bool] = {}
for vorher, nachher in zip(tankvorgaenge, tankvorgaenge[1:]):
von = _zeit(vorher.get("ts"))
bis = _zeit(nachher.get("ts"))
if von is None or bis is None or bis <= von:
continue
# Der Beleg der SPAETEREN Betankung nennt, was seit der frueheren
# verbraucht wurde - er ist der Zaehler.
getankt = beleg_liter(nachher)
if getankt is None or getankt <= 0:
continue
# Nur Fahrten, die GANZ im Intervall liegen. Eine Fahrt, die ueber
# eine Betankung hinweggeht, laesst sich keinem der beiden Intervalle
# zuordnen - sie bleibt ohne Faktor, was richtig ist.
im_fenster = []
for f in fahrten:
start = _zeit(f.get("ts_start"))
ende = _zeit(f.get("ts_end"))
if start is None or ende is None:
continue
if start >= von and ende <= bis:
im_fenster.append(f)
mit_messung = [f for f in im_fenster if _liter_der_fahrt(f) is not None]
if not mit_messung:
continue
if all(f.get("verbrauch_faktor") is not None for f in mit_messung):
continue
# Deckt die Fahrtmenge die tatsaechlich gefahrene Strecke ab? Das ist
# die Pruefung des NENNERS und kommt vor der teuren Voll-Pruefung, weil
# eine Luecke in den Fahrten der haeufigere Fall ist.
strecke_fahrten = sum(
float(f["distance_km"]) for f in im_fenster if f.get("distance_km")
)
spanne = await _tacho_spanne(k, km_sensor, von, bis)
if spanne is None:
_LOGGER.debug(
"Tachospanne fuer %s bis %s nicht bestimmbar - keine Korrektur",
von.isoformat(), bis.isoformat(),
)
continue
odo_von, odo_bis = spanne
# Passt das eingetragene Datum zum Kilometerstand? Nur bei Handeingabe
# kann es auseinanderfallen - siehe _datum_passt().
schief = [
(v, t) for v, t in ((vorher, odo_von), (nachher, odo_bis))
if not _datum_passt(v, t)
]
if schief:
vorgang, tacho = schief[0]
_LOGGER.warning(
"Tankvorgang %s nennt %s km, der Tacho stand zu seinem Zeitpunkt "
"(%s) aber bei %s km - Datum und Kilometerstand passen nicht "
"zusammen, deshalb keine Korrektur",
vorgang.get("tank_id"), vorgang.get("odometer_km"),
vorgang.get("ts"), round(tacho, 1),
)
continue
deckung = strecke_fahrten / (odo_bis - odo_von)
if not DECKUNG_MIN <= deckung <= DECKUNG_MAX:
_LOGGER.warning(
"Die erkannten Fahrten decken %.0f %% der gefahrenen Strecke zwischen "
"%s und %s ab (%.1f km aus %d Fahrten) - ausserhalb von %.0f-%.0f %%. "
"Der Verbrauchsfaktor wuerde fehlende Daten beschreiben und nicht den "
"Geber, deshalb keine Korrektur",
deckung * 100, von.isoformat(), bis.isoformat(),
strecke_fahrten, len(im_fenster), DECKUNG_MIN * 100, DECKUNG_MAX * 100,
)
continue
# Erst jetzt der teure Teil: beide Enden muessen Volltankungen sein.
for vorgang, zeitpunkt in ((vorher, von), (nachher, bis)):
kennung = str(vorgang.get("tank_id"))
if kennung not in voll_bekannt:
voll_bekannt[kennung] = await _ist_voll(k, tank_sensor, zeitpunkt, volumen)
if not (voll_bekannt[str(vorher.get("tank_id"))]
and voll_bekannt[str(nachher.get("tank_id"))]):
_LOGGER.debug(
"Kein Voll-zu-Voll-Intervall zwischen %s und %s - keine Korrektur",
von.isoformat(), bis.isoformat(),
)
continue
gerechnet = sum(_liter_der_fahrt(f) or 0.0 for f in mit_messung)
if gerechnet <= 0:
continue
# Kein Ober- oder Unterwert am Faktor: der Nenner ist oben geprueft,
# der Zaehler durch Beleg und Volltankung. Was jetzt herauskommt, ist
# die Messung.
gerundet = round(getankt / gerechnet, 4)
for f in mit_messung:
roh = float(f["verbrauch_roh_l_100km"])
aenderungen = {
"verbrauch_faktor": gerundet,
"verbrauch_l_100km": round(roh * gerundet, 1),
}
await k.ablage.fahrt_aktualisieren(f["trip_id"], aenderungen)
f.update(aenderungen)
_LOGGER.info(
"Verbrauchsfaktor %.3f auf %d Fahrten zwischen %s und %s angewandt "
"(%.1f l laut Beleg, %.1f l gerechnet, Streckendeckung %.0f %%)",
gerundet, len(mit_messung), von.isoformat(), bis.isoformat(),
getankt, gerechnet, deckung * 100,
)