Rueckblick misst auf Meter, Streckenwahl gemeinsam (2026.9.1.14)

Der erste Wurf (2026.9.1.12) verwarf jedes Importfenster mit distanz == 0. Zu
grob: seit der GNSS-Verfeinerung messen wir Meter, und eine Fahrt von 400
Metern steht im CAN-Wert als Null. Der Eigentuemer hat es auf den Punkt
gebracht - "0,0 moechte ich nicht, ab 0,1 schon".

Der Rueckblick liest jetzt ebenfalls den GNSS-Zaehler und entscheidet auf eine
Nachkommastelle.

Dabei stand die Toleranzlogik kurz doppelt im Code - in screening.py und in
historienimport.py. Genau die Doppelung, die dieses Projekt bei
UNPLAUSIBLE_KMH und MINDESTDAUER_S schon einmal teuer bezahlt hat. Sie liegt
jetzt gemeinsam in verlauf.py:

  strecke_waehlen(grob, fein) - die feine Zahl gilt, solange sie in der
  Rundungsunschaerfe des Ankers liegt, oder wenn es keinen Anker gibt.

  ist_gefahren(distanz) - ab 0,1 km ja. Eine unbekannte Strecke gilt als
  gefahren; nur die gemessene Null ist ein Nein.

VERIFIZIERT: 13 Faelle gegen beide Funktionen, darunter die zwei echten
Fahrten, die 400-Meter-Fahrt, die 100-Meter-Untergrenze, 40 Meter, fehlender
Anker, fehlender GNSS-Wert, gedrifteter Zaehler und beide Seiten der Grenze.
Import live: 1 Fenster uebersprungen, null 0-km-Fahrten im Bestand. Livepfad
live: vier alte Fahrten korrekt abgelehnt.

NICHT live gezeigt: ein Fenster mit 0,1-0,9 km, das der Import nun behaelt -
der Recorder enthaelt kein geschlossenes Fenster dieser Groesse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 18:47:51 +02:00
parent be95b40d79
commit 09c7461892
7 changed files with 136 additions and 26 deletions
+47 -1
View File
@@ -1,6 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-01** (Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle,
**Last updated: 2026-09-01** (Rueckblick ohne Nullfahrten, Streckenwahl gemeinsam in `verlauf.py`,
`2026.9.1.14`, Abschnitt BF. Davor: Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle,
`2026.9.1.10`, Abschnitt BE. Davor: Nachlauf-Abzug nur mit zugeordnetem `TRIP_SENSOR`, `2026.9.1.9`;
davor „502" beim Neustart entschärft, OTA-Bündel auf `2026.9.1.8`
nachgezogen, Abschnitt BC. Davor: „Geparkt seit" kommt von der Zündung und wird nach einem Neustart
@@ -6833,6 +6834,51 @@ Verifiziert: `strecke_aus_zaehler()` gegen beide echten Fahrten plus sechs Randf
(zwei Rücksetzungen, ein Punkt, leer, unlesbare Werte); `py_compile`, `tsc --noEmit` sauber,
Bündel und Manifest auf `2026.9.1.10` mit demselben SHA-256.
## BF. Der Rueckblick legt keine Nullfahrten mehr an — und misst jetzt auf Meter (2026.9.1.12.14)
Der Eigentuemer hatte auf der realen Instanz alle Nullfahrten geloescht, und **bei jedem Import kam
eine davon wieder**: 31.08.2026, 14:4815:21, 33 Minuten, 209177 → 209177 km, vmax 3 km/h.
`MINDESTDAUER_S` liess sie durch, weil sie mit 33 Minuten lang genug war; eine Pruefung auf die
Strecke gab es im Rueckblick nie.
### Erst zu grob gedacht, dann korrigiert
Mein erster Wurf verwarf jedes Fenster mit `distanz == 0`. Der Eigentuemer hat richtig eingewandt:
seit der GNSS-Verfeinerung (Abschnitt BE) messen wir Meter — `„0,0 möchte ich nicht, ab 0,1 schon"`.
Eine Fahrt von 400 Metern steht im CAN-Wert als Null und waere verlorengegangen, obwohl gefahren
wurde.
Der Rueckblick liest deshalb jetzt ebenfalls den GNSS-Zaehler und entscheidet auf **eine
Nachkommastelle**.
### Eine Entscheidung, eine Stelle
Dabei stand die Toleranzlogik kurz doppelt im Code — in `screening.py` und in
`historienimport.py`. Genau die Doppelung, die dieses Projekt bei `UNPLAUSIBLE_KMH` und
`MINDESTDAUER_S` schon einmal teuer bezahlt hat. Sie liegt jetzt in `verlauf.py`:
- **`strecke_waehlen(grob, fein)`** — die feine Zahl gilt, solange sie in der Rundungsunschaerfe des
Ankers liegt, oder wenn es gar keinen Anker gibt. Sonst hat der Fahrzeugwert recht.
- **`ist_gefahren(distanz)`** — ab 0,1 km ja, darunter nein. Eine **unbekannte** Strecke gilt als
gefahren: eine Fahrt zu verwerfen, die man nicht gemessen hat, waere schlechter als eine ohne
Streckenangabe zu behalten. Nur die gemessene Null ist ein Nein.
Beide Wege rufen dieselben Funktionen; der Rueckblick meldet die uebersprungenen Fenster als eigene
Zahl (`„Zuendungen ohne gefahrene Strecke"`) in beiden Oberflaechen, damit er nichts stillschweigend
weglaesst.
### Verifiziert — und was nicht
- **13 Faelle gegen `strecke_waehlen`/`ist_gefahren`**, darunter die beiden echten Fahrten, die
400-Meter-Fahrt, die 100-Meter-Untergrenze, 40 Meter (keine Fahrt), fehlender Anker, fehlender
GNSS-Wert, gedrifteter Zaehler und beide Seiten der Toleranzgrenze.
- **Import live**: `fahrten_nicht_gefahren: 1`, null Fahrten mit 0 km im Bestand.
- **Livepfad live**: vier alte Fahrten korrekt abgelehnt, Meldung jetzt ohne die Konstante im Text.
- **Nicht live gezeigt:** ein Fenster mit 0,10,9 km, das der Import nun *behaelt*. Der Recorder
enthaelt kein geschlossenes Fenster dieser Groesse — die Rangierfahrten vom 31.08. liegen alle
innerhalb einer einzigen langen `trip_status`-Phase. Nachweisbar bleibt es an den 13 Faellen; live
zeigt es sich bei der naechsten kurzen Fahrt.
## Working conventions (observed — keep them)
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
@@ -1 +1 @@
{"version":"2026.9.1.12","sha256":"5a2d5547cbc6db6850e46892ea422de08642f86fc634b5887322389b4e0af589","bytes":265724,"gebaut":"2026-09-01T16:35:22Z"}
{"version":"2026.9.1.14","sha256":"f1fa3a91435bfc80f3f4245e52c488e3487fe12e5031349e1d7d20ca757d4b7d","bytes":265727,"gebaut":"2026-09-01T16:47:50Z"}
@@ -48,6 +48,9 @@ from .verlauf import (
MINDESTDAUER_S,
durchschnitt_kmh,
fahrtsignal,
ist_gefahren,
strecke_aus_zaehler,
strecke_waehlen,
hoechstwert_im_fenster,
route_aus_verlauf,
UNPLAUSIBLE_KMH,
@@ -163,6 +166,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
bestehende = await k.ablage.fahrten_lesen()
km_verlauf = verlaeufe["km"]
gnss_verlauf = verlaeufe.get("gnss_km") or []
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
tank_liter_verlauf = verlaeufe["tank_liter"]
@@ -186,10 +190,22 @@ 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)
if distanz == 0:
# Der Kilometerstand stand an beiden Enden gleich: das Fahrzeug hat
# sich nicht bewegt. Zündung an, aber nicht gefahren - Radio, Tür
# öffnen, warten. Keine Fahrt.
# Der Kilometerstand des Fahrzeugs löst nur ganze Kilometer auf. Wo der
# geräteeigene GNSS-Zähler zugeordnet ist, liefert er Meter - dieselbe
# Verfeinerung wie im Livepfad (screening._gnss_verfeinern), damit
# Rückblick und Livepfad dieselbe Fahrt nicht unterschiedlich bewerten.
#
# Sie entscheidet hier zusätzlich über Sein oder Nichtsein: eine Fahrt
# von 400 Metern steht im CAN-Wert als Null und wäre sonst verworfen
# worden, obwohl gefahren wurde.
fein = strecke_aus_zaehler(
[p for p in gnss_verlauf if f_start <= p[0] <= f_ende]
)
distanz, gnss_verwendet = strecke_waehlen(distanz, fein)
if not ist_gefahren(distanz):
# Gemessene Null: das Fahrzeug hat sich nicht bewegt. Zündung an,
# aber nicht gefahren - Radio, Tür öffnen, warten. Keine Fahrt.
#
# Die Dauer allein reicht als Prüfung nicht: der Eigentümer hatte
# nach dem Löschen aller Nullfahrten bei JEDEM Import wieder eine
@@ -197,12 +213,14 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
# 209177 -> 209177 km, vmax 3 km/h). MINDESTDAUER_S ließ sie durch,
# weil sie mit 33 Minuten lang genug war.
#
# Nur die GEMESSENE Null wird verworfen, nicht die unbekannte:
# liegt kein Kilometerstand vor, bleibt distanz None und die Fahrt
# wird angelegt. Der Preis, bewusst getragen: eine echte Fahrt unter
# einem Kilometer, bei der der Zähler nicht umsprang, geht dabei
# verloren - sie wäre aber genau der Nulleintrag, den der
# Eigentümer nicht im Bestand haben will.
# Auf eine Nachkommastelle gerundet, nicht auf Gleichheit mit 0:
# ab 0,1 km ist es eine Fahrt (Ansage des Eigentümers, 01.09.2026),
# darunter zeigt die Oberfläche ohnehin "0,0 km" an und der Eintrag
# sähe aus wie die Nullfahrten, die er gerade ausgeräumt hat.
#
# Nur die GEMESSENE Null wird verworfen, nicht die unbekannte: liegt
# weder Kilometerstand noch GNSS-Zähler vor, bleibt distanz None und
# die Fahrt wird angelegt.
nicht_gefahren += 1
continue
@@ -221,7 +239,13 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
fahrt = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
"distance_km": distanz,
"km_quelle": "sensor" if distanz is not None else None,
"km_quelle": (
None
if distanz is None
else "odometer+gnss"
if gnss_verwendet
else "sensor"
),
"odo_start": odo_start,
"odo_end": odo_end,
"avg_speed_kmh": durchschnitt,
@@ -468,6 +492,7 @@ async def importieren(k: Koordinator, start: object, ende: object) -> None:
verlaeufe = {
"zuendung": await verlauf_lesen(k.hass, fahrtsignal(werte), von, bis),
"km": await verlauf_lesen(k.hass, werte.KM_SENSOR, von, bis),
"gnss_km": await verlauf_lesen(k.hass, werte.GNSS_KM_SENSOR, von, bis),
"tank": await verlauf_lesen(k.hass, werte.TANK_SENSOR, von, bis),
"tank_liter": await verlauf_lesen(k.hass, werte.TANK_LITER_SENSOR, von, bis),
"tank_distanz": await verlauf_lesen(k.hass, werte.TANK_DISTANZ_SENSOR, von, bis),
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.1.12",
"version": "2026.9.1.14",
"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"],
+11 -12
View File
@@ -32,11 +32,11 @@ import logging
from typing import TYPE_CHECKING
from .verlauf import (
GNSS_TOLERANZ_KM,
UNPLAUSIBLE_KMH,
durchschnitt_kmh,
hoechstwert_im_fenster,
strecke_aus_zaehler,
strecke_waehlen,
naechster_wert,
route_aus_verlauf,
verbrauch_aus_literstaenden,
@@ -259,23 +259,22 @@ async def _gnss_verfeinern(k: Koordinator, gnss_sensor: str, fahrt: dict) -> Non
punkte = await verlauf_lesen(k.hass, gnss_sensor, start, ende)
fein = strecke_aus_zaehler(punkte)
if fein is None:
gewaehlt, genommen = strecke_waehlen(grob, fein)
if not genommen:
if fein is not None:
_LOGGER.info(
"Fahrt %s: GNSS-Strecke %s km passt nicht zum Kilometerstand "
"(%s km) - der Fahrzeugwert bleibt stehen",
fahrt.get("trip_id"), fein, grob,
)
return
if abs(fein - grob) > GNSS_TOLERANZ_KM:
_LOGGER.info(
"Fahrt %s: GNSS-Strecke %s km weicht um mehr als %s km vom "
"Kilometerstand (%s km) ab - der Fahrzeugwert bleibt stehen",
fahrt.get("trip_id"), fein, GNSS_TOLERANZ_KM, grob,
)
return
aenderungen = {"distance_km": fein, "km_quelle": "odometer+gnss"}
aenderungen = {"distance_km": gewaehlt, "km_quelle": "odometer+gnss"}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
fahrt.update(aenderungen)
_LOGGER.info(
"Fahrt %s: Strecke auf %s km verfeinert (Kilometerstand sagte %s km)",
fahrt.get("trip_id"), fein, grob,
fahrt.get("trip_id"), gewaehlt, grob,
)
@@ -283,6 +283,46 @@ def route_aus_verlauf(
GNSS_TOLERANZ_KM = 1.0
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.