Vollaudit: sechs echte Fehler in Backend, Panel und companion-app behoben
- companion-app Batterie.tsx: Generatorspannung zeigte sich faelschlich als Ruhespannung/verzerrte den SOH-Trend, Panel-Filter (AGM_RUHE_MAX_V) fehlte - Manuell angelegte Fahrten/Tankvorgaenge trugen naive Zeitstempel und wurden von der Import-Dublettenpruefung stillschweigend uebersprungen; neue gemeinsame zeit_normalisiert() in verlauf.py schliesst die Luecke - Batteriespannungsverlauf fehlte im Backup (sicherung.py) - Tankvorgangs-Import verlor stillschweigend aeltere Tankvorgaenge vor Beginn der Litersensor-Historie; laeuft jetzt zweigleisig (Prozent + Liter) - Panel-Statistikseite behauptete faelschlich, der Verbrauch je Fahrt komme vom Fahrzeug (OBD) - ist eine Naeherung aus dem Tankfuellstand - companion-app Statistik.tsx: Arbeitsweg-Segment war noch rot statt neutral Version 2026.8.27.19, OTA-Buendel neu gebaut, im Testcontainer verifiziert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,6 +54,7 @@ from .verlauf import (
|
||||
wert_bei,
|
||||
wert_in_naehe,
|
||||
zahl,
|
||||
zeit_normalisiert,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -78,17 +79,7 @@ MINDESTDAUER_S = 60
|
||||
# genau wie bei fehlendem Kilometerstand.
|
||||
|
||||
|
||||
def als_zeit(wert: object) -> datetime.datetime | None:
|
||||
"""Akzeptiert, was die Oberfläche schickt: ISO mit oder ohne Zeitzone.
|
||||
|
||||
Ohne Zeitzone gilt die lokale Zeit von Home Assistant - der Nutzer wählt
|
||||
im Formular schließlich Ortszeit, keine UTC."""
|
||||
if not wert:
|
||||
return None
|
||||
ts = datetime.datetime.fromisoformat(str(wert))
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.astimezone()
|
||||
return ts.astimezone(datetime.UTC)
|
||||
als_zeit = zeit_normalisiert # lokaler Name beibehalten, Funktion lebt jetzt in verlauf.py (siehe dort)
|
||||
|
||||
|
||||
def _fahrtfenster(
|
||||
@@ -132,14 +123,24 @@ def _ueberschneidet(
|
||||
start: datetime.datetime, ende: datetime.datetime, bestehende: list[dict]
|
||||
) -> bool:
|
||||
"""True, wenn sich [start, ende] mit einer bereits erfassten Fahrt
|
||||
überschneidet. Verhindert Dubletten beim wiederholten Import."""
|
||||
überschneidet. Verhindert Dubletten beim wiederholten Import.
|
||||
|
||||
Läuft über zeit_normalisiert() statt über ein rohes fromisoformat(): eine
|
||||
von Hand angelegte oder bearbeitete Fahrt (fahrterkennung.py) wird seit
|
||||
dieser Funktion ebenfalls über zeit_normalisiert() gespeichert und trägt
|
||||
deshalb einen zeitzonenbewussten Zeitstempel - vorher wurde ein naiver
|
||||
Zeitstempel hier stillschweigend übersprungen (kein Vergleich, keine
|
||||
Erkennung), sodass ein von Hand angelegter Datensatz nie gegen einen
|
||||
späteren Import geschützt war. Ältere, bereits gespeicherte naive
|
||||
Zeitstempel werden hier trotzdem korrekt behandelt: zeit_normalisiert()
|
||||
interpretiert sie als lokale Zeit, genau wie beim Schreiben."""
|
||||
for f in bestehende:
|
||||
try:
|
||||
f_start = datetime.datetime.fromisoformat(f.get("ts_start")) # type: ignore[arg-type]
|
||||
f_ende = datetime.datetime.fromisoformat(f.get("ts_end")) # type: ignore[arg-type]
|
||||
f_start = zeit_normalisiert(f.get("ts_start"))
|
||||
f_ende = zeit_normalisiert(f.get("ts_end"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if f_start.tzinfo is None or f_ende.tzinfo is None:
|
||||
if f_start is None or f_ende is None:
|
||||
continue
|
||||
if start < f_ende and f_start < ende:
|
||||
return True
|
||||
@@ -215,64 +216,88 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
|
||||
|
||||
async def _tankvorgaenge_importieren(k: Koordinator, verlaeufe: dict) -> dict:
|
||||
"""Tankvorgänge aus dem Füllstands- oder Tankvolumenverlauf - dieselbe
|
||||
"""Tankvorgänge aus dem Füllstands- und/oder Tankvolumenverlauf - dieselbe
|
||||
Tiefststand-Logik wie in der Live-Erkennung (tankerkennung.py): jeder
|
||||
Anstieg über die Schwelle gegen den zuletzt gesehenen Tiefststand ist ein
|
||||
Tankvorgang, nicht jeder Anstieg gegen den unmittelbar vorherigen Wert.
|
||||
Dieselbe Präferenz wie live auch: ist ein Litersensor zugeordnet,
|
||||
übernimmt der - genauer, und der Anstieg dient gleich als grobe
|
||||
Vorbelegung für die getankte Menge."""
|
||||
mit_liter = bool(k.zuordnung.werte.TANK_LITER_SENSOR)
|
||||
verlauf = verlaeufe["tank_liter"] if mit_liter else verlaeufe["tank"]
|
||||
if not verlauf:
|
||||
|
||||
Ist ein Litersensor zugeordnet, übernimmt der für den Zeitraum, den seine
|
||||
eigene Verlaufshistorie abdeckt - genauer, und der Anstieg dient gleich
|
||||
als grobe Vorbelegung für die getankte Menge. Für den Teil DAVOR (der
|
||||
Litersensor wurde z. B. erst später zugeordnet, oder der recorder hält
|
||||
seine Historie nicht so weit zurück wie die des Prozentsensors) läuft
|
||||
zusätzlich der Prozentsensor - sonst würde ein Import über einen
|
||||
Zeitraum, der vor die Liter-Historie zurückreicht, die dort tatsächlich
|
||||
vorhandenen älteren Tankvorgänge stillschweigend verlieren, obwohl der
|
||||
Prozentsensor sie längst hätte erkennen können. Beide Läufe teilen sich
|
||||
`bekannte_zeiten`/`neue`, damit derselbe physische Tankvorgang nicht
|
||||
doppelt entsteht, falls sich die beiden Verläufe zeitlich überschneiden."""
|
||||
prozent_verlauf = verlaeufe["tank"]
|
||||
liter_verlauf = verlaeufe["tank_liter"] if k.zuordnung.werte.TANK_LITER_SENSOR else []
|
||||
if not prozent_verlauf and not liter_verlauf:
|
||||
return {"angelegt": 0, "uebersprungen": 0}
|
||||
|
||||
schwelle = LITER_SCHWELLE if mit_liter else schwelle_prozent(await k.ablage.profil_lesen())
|
||||
km_verlauf = verlaeufe["km"]
|
||||
bestehende = await k.ablage.tankvorgaenge_lesen()
|
||||
fenster_s = TANK_DUBLETTE_MIN * 60
|
||||
|
||||
# zeit_normalisiert() statt rohem fromisoformat(): siehe die identische
|
||||
# Begründung in _ueberschneidet() oben - ein von Hand angelegter oder
|
||||
# bearbeiteter Tankvorgang (belege.py) trägt seither ebenfalls einen
|
||||
# zeitzonenbewussten Zeitstempel und wurde vorher hier stillschweigend
|
||||
# aus der Dublettenprüfung ausgeschlossen.
|
||||
bekannte_zeiten: list[datetime.datetime] = []
|
||||
for t in bestehende:
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(t.get("ts")) # type: ignore[arg-type]
|
||||
ts = zeit_normalisiert(t.get("ts"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if ts.tzinfo is not None:
|
||||
if ts is not None:
|
||||
bekannte_zeiten.append(ts)
|
||||
|
||||
angelegt = uebersprungen = 0
|
||||
neue: list[dict] = []
|
||||
tiefststand: float | None = None
|
||||
|
||||
for ts, wert in verlauf:
|
||||
aktuell = zahl(wert)
|
||||
if aktuell is None:
|
||||
continue
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
def _verlauf_abarbeiten(verlauf: list, schwelle: float, mit_liter: bool) -> None:
|
||||
nonlocal angelegt, uebersprungen
|
||||
tiefststand: float | None = None
|
||||
for ts, wert in verlauf:
|
||||
aktuell = zahl(wert)
|
||||
if aktuell is None:
|
||||
continue
|
||||
if tiefststand is None or aktuell <= tiefststand:
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
anstieg = aktuell - tiefststand
|
||||
if anstieg < schwelle:
|
||||
continue
|
||||
|
||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
||||
uebersprungen += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
|
||||
odometer_km = wert_bei(km_verlauf, ts)
|
||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
if mit_liter:
|
||||
tankvorgang["liters"] = round(anstieg, 1)
|
||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
||||
ts.isoformat(), odometer_km, neue + bestehende
|
||||
)
|
||||
neue.append(tankvorgang)
|
||||
bekannte_zeiten.append(ts)
|
||||
angelegt += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
anstieg = aktuell - tiefststand
|
||||
if anstieg < schwelle:
|
||||
continue
|
||||
|
||||
if any(abs((bekannt - ts).total_seconds()) < fenster_s for bekannt in bekannte_zeiten):
|
||||
uebersprungen += 1
|
||||
tiefststand = aktuell
|
||||
continue
|
||||
|
||||
odometer_km = wert_bei(km_verlauf, ts)
|
||||
tankvorgang = leerer_tankvorgang(ts.isoformat(), "import")
|
||||
tankvorgang["odometer_km"] = odometer_km
|
||||
if mit_liter:
|
||||
tankvorgang["liters"] = round(anstieg, 1)
|
||||
tankvorgang["distance_km"] = _distanz_zum_vorherigen(
|
||||
ts.isoformat(), odometer_km, neue + bestehende
|
||||
)
|
||||
neue.append(tankvorgang)
|
||||
bekannte_zeiten.append(ts)
|
||||
angelegt += 1
|
||||
tiefststand = aktuell
|
||||
if liter_verlauf:
|
||||
grenze = liter_verlauf[0][0]
|
||||
aelterer_teil = [p for p in prozent_verlauf if p[0] < grenze]
|
||||
if aelterer_teil:
|
||||
_verlauf_abarbeiten(aelterer_teil, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||
_verlauf_abarbeiten(liter_verlauf, LITER_SCHWELLE, True)
|
||||
else:
|
||||
_verlauf_abarbeiten(prozent_verlauf, schwelle_prozent(await k.ablage.profil_lesen()), False)
|
||||
|
||||
await k.ablage.tankvorgaenge_ergaenzen(neue)
|
||||
return {"angelegt": angelegt, "uebersprungen": uebersprungen}
|
||||
|
||||
Reference in New Issue
Block a user