Fahrtstrecke auf 100 m: CAN als Anker, GNSS als Nachkommastelle (2026.9.1.10)

Zwei echte Fahrten am 01.09.2026 haben die Datenform geliefert, auf die
2026.9.1.5 gewartet hat.

strecke_aus_zaehler() summiert die Zuwaechse des geraeteeigenen
Kilometerzaehlers und behandelt einen Ruecksprung als Ruecksetzung. Weder der
rohe Zaehler noch die Teilstrecken taugen allein: der Zaehler verliert bei
einer Ruecksetzung alles Vorherige (Fahrt 1: 0,415 km), die Teilstrecken
verlieren einzelne Datensaetze (Fahrt 2: 4 von 50 null, 0,2 km zu wenig). An
jedem Datensatz sind beide identisch - sie gehen nur verschieden kaputt.

_gnss_verfeinern() ersetzt die grobe Strecke nur, wenn die feine innerhalb der
Rundungsunschaerfe des Ankers liegt (GNSS_TOLERANZ_KM = 1.0; beide Enden des
CAN-Werts sind auf ganze Kilometer gerundet). Sonst behaelt der Fahrzeugwert
recht.

VERIFIZIERT gegen die echten Fahrten:
- Fahrt 1 (mit Ruecksetzung): 6,958 km, Google Maps sagt 7,1
- Fahrt 2 (sauber): 6,816 km
- live: 'Strecke auf 6.896 km verfeinert (Kilometerstand sagte 7.0 km)'
- drei aeltere Fahrten korrekt abgelehnt (209178 km gegen 21 km Anker)
- sechs Randfaelle: zwei Ruecksetzungen, ein Punkt, leer, unlesbare Werte

Nebenbei bestaetigt: NACHLAUF_S = 900 (Zuendung aus 16:33:39, Trip-Signal aus
16:48:44, gespeichertes Ende 16:33:36); der Rueckwaertssprung-Schutz griff live
beim Fahrzeugwechsel (-147361 km verworfen, Fahrt blieb offen); die
RPM-Zuendung prellt nicht; das Bewegungstor meldet wieder Stillstand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 17:31:33 +02:00
parent 0124254ea9
commit af83805c34
7 changed files with 186 additions and 4 deletions
+79 -1
View File
@@ -1,6 +1,7 @@
# AGENTS.md — Project state, review findings, open items, and working rules
**Last updated: 2026-09-01** (Nachlauf-Abzug nur mit zugeordnetem `TRIP_SENSOR`, `2026.9.1.9`;
**Last updated: 2026-09-01** (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
aus der Aufzeichnung nachgeholt, Manifest `2026.9.1.7`,
@@ -6728,6 +6729,83 @@ Nachgewiesen: Manifest, `bundle.json` und die ausgelieferte Zip tragen alle `202
denselben SHA-256 `c2f9192e…`; `tsc --noEmit` sauber, 165/165 Tests grün, Panel als **Modul**
geparst (Abschnitt AQ), `audi_ha_test` fehlerfrei gestartet.
## BE. Die Fahrtstrecke auf 100 Meter: CAN als Anker, GNSS als Nachkommastelle (2026.9.1.10)
Zwei echte Fahrten am 01.09.2026 haben die Datenform geliefert, auf die Abschnitt BA gewartet hat.
Beide Male schließt die Rechnung, und beide Male auf eine andere Weise — deshalb war das Warten
richtig.
### Was gemessen wurde
| Quelle | Fahrt 1 (13:42, mit Rücksetzung) | Fahrt 2 (16:20, sauber) |
|---|---|---|
| **Zählerzuwächse mit Rücksprungbehandlung** | **6,958** | **6,816** |
| GPS-Spur | 7,01 | 6,74 |
| Geschwindigkeit integriert | 6,91 | 6,70 |
| Teilstrecken summiert | 6,928 | 6,614 |
| CAN-Kilometerstand (ganze km) | 7 | 6 |
| Google Maps | 7,1 | — |
### Der Zähler, nicht die Teilstrecken — und warum ich zweimal falsch lag
Erst hielt ich den **Gesamtzähler** für robuster (Abschnitt BA), nach Fahrt 1 die **Teilstrecken**,
nach Fahrt 2 wieder den Zähler. Die Auflösung: es sind keine zwei Messungen. An jedem einzelnen
Datensatz gilt `Teilstrecke == Differenz des Zählers`, auf drei Nachkommastellen genau. Sie
unterscheiden sich nur darin, **wie sie kaputtgehen**:
- Der **Zähler** verliert bei einer Rücksetzung alles Vorherige (Fahrt 1: 0,415 km).
- Die **Teilstrecken** verlieren einzelne Datensätze — in Fahrt 2 waren **4 von 50 null**, obwohl
gefahren wurde, Summe dadurch 0,2 km zu niedrig.
`strecke_aus_zaehler()` summiert deshalb die **Zuwächse** und behandelt einen Rücksprung als
Rücksetzung (der neue Wert ist dann selbst der Zuwachs). Damit fällt beides weg: unempfindlich
gegen Rücksetzungen, und keine verlorenen Datensätze. Ohne Rücksetzung degeneriert es zu Ende
minus Anfang.
Der Eigentümer hat dazu angemerkt, dass im Endzustand alle Fahrten wie Fahrt 2 verlaufen — er
steckt den Dongle nur in der Entwicklungsphase um. Die Rücksprungbehandlung ist damit kein
Kernstück mehr, sondern ein billiges Netz für Batteriewechsel und Werkstattbesuch.
### Woher die Rücksetzung mitten in der Fahrt kam
Um 13:43:20, bei 65 km/h und laufendem Motor, ging `total_calculated_mileage` von 0,415 auf 0. Im
**selben Datensatz** zählte `total_vehicle_mileage_read_from_can` zum ersten Mal hoch
(61809 → 61810), ebenso `distance_traveled_since_codes_cleared`. Kein Verbindungsabbruch, kein
Neustart, Bordspannung stabil bei 14,4 V — das Gerät übernahm offenbar seinen konfigurierten
Startwert (`11807 = 0`), als der CAN-Kilometerstand erstmals gültig wurde. Nicht bewiesen; ein
Prüfstein wäre, `11807` auf den echten Kilometerstand zu setzen und zu sehen, ob der Sprung dann
dorthin geht statt auf null.
### Die Verfeinerung ersetzt nicht, sie prüft
`_gnss_verfeinern()` setzt `distance_km` nur dann auf den feinen Wert, wenn er **innerhalb der
Rundungsunschärfe des Ankers** liegt (`GNSS_TOLERANZ_KM = 1.0` — beide Enden des CAN-Werts sind auf
ganze Kilometer gerundet). Sonst behält der Fahrzeugwert recht und es bleibt bei `"odometer"`.
Live nachgewiesen, beides im selben Lauf:
- Fahrt 2: `Strecke auf 6.896 km verfeinert (Kilometerstand sagte 7.0 km)`, `km_quelle` jetzt
`"odometer+gnss"`.
- Drei ältere Fahrten aus der Zeit vor der GNSS-Umstellung: `GNSS-Strecke 209178.0 km weicht um
mehr als 1.0 km vom Kilometerstand (21.0 km) ab - der Fahrzeugwert bleibt stehen`. Genau dafür
ist die Schranke da.
### Nebenbei bestätigt
- **`NACHLAUF_S = 900` stimmt.** Fahrt 2: Zündung aus 16:33:39, Trip-Signal aus 16:48:44 — 905 s
Abstand. Das gespeicherte Fahrtende steht auf **16:33:36**, also drei Sekunden neben der
tatsächlichen Zündung.
- **Der Rückwärtssprung-Schutz griff live** und genau wie vorhergesagt: Fahrt 1 lief über einen
Fahrzeugwechsel (209177 → 61816), `-147361 km` wurden verworfen, die Fahrt blieb „offen".
- **Die RPM-Zündung prellt nicht** — zwei echte Wechsel über beide Fahrten, kein Paar unter zwei
Sekunden. `Avg Const = 10` (1 s) bleibt.
- **Das Bewegungstor funktioniert** seit `138 = 10` (Beschleunigungssensor + CAN Speed statt GNSS):
`movement` meldet wieder Stillstand, vorher hing es tagelang auf „on".
Verifiziert: `strecke_aus_zaehler()` gegen beide echten Fahrten plus sechs Randfälle
(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.
## Working conventions (observed — keep them)
- German is the project language: identifiers, comments, commits, UI texts. Exceptions:
+4 -1
View File
@@ -40,7 +40,10 @@ export interface Fahrt {
duration_s: number;
distance_km: number | null;
/** "odometer" ist der einzige Wert, den das Backend je erzeugt (§7.1). */
km_quelle: "odometer" | "gps" | null;
/* "odometer+gnss": der Kilometerstand des Fahrzeugs als Anker, die
Nachkommastelle vom GNSS-Zaehler des Geraets - siehe screening.py.
"sensor"/"manuell" kommen aus dem Rueckblick bzw. von Hand. */
km_quelle: "odometer" | "odometer+gnss" | "gps" | "sensor" | "manuell" | null;
odo_start: number | null;
odo_end: number | null;
art: Fahrtart;
@@ -1 +1 @@
{"version":"2026.9.1.9","sha256":"bdf895932e11e336474b4441a66912dd273e5f22b3bbf82e9b1cd29d6447a652","bytes":265713,"gebaut":"2026-09-01T10:24:49Z"}
{"version":"2026.9.1.10","sha256":"ea987e424774f4d2d0e7ea87f38f4b265f0605730515cacfd7193b2bb309b647","bytes":265700,"gebaut":"2026-09-01T15:30:31Z"}
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.9.1.9",
"version": "2026.9.1.10",
"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"],
@@ -32,9 +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,
naechster_wert,
route_aus_verlauf,
verbrauch_aus_literstaenden,
@@ -159,6 +161,14 @@ async def durchfuehren(k: Koordinator) -> None:
)
fahrt["avg_speed_kmh"] = schnitt
# Erst nach den Kilometerstaenden: die Verfeinerung braucht die grobe
# Strecke als Anker und laeuft nur auf Fahrten, die sie schon haben.
gnss_sensor = k.zuordnung.werte.GNSS_KM_SENSOR
if gnss_sensor:
for fahrt in fahrten:
if fahrt.get("km_quelle") == "odometer":
await _gnss_verfeinern(k, gnss_sensor, fahrt)
tempo_sensor = k.zuordnung.werte.GESCHWINDIGKEIT_SENSOR
if tempo_sensor:
ohne_vmax = [f for f in fahrten if f.get("vmax_kmh") is None]
@@ -223,6 +233,52 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
fahrt.update(aenderungen)
async def _gnss_verfeinern(k: Koordinator, gnss_sensor: str, fahrt: dict) -> None:
"""Ersetzt die auf ganze Kilometer gerundete Strecke durch die metergenaue
des Geraets - aber nur, wenn beide zueinander passen.
Der Kilometerstand des Fahrzeugs bleibt der Anker: er ist der Tacho, er
driftet nicht, und er ist die Zahl, die der Fahrer im Cockpit sieht. Nur
loest er eben ganze Kilometer auf. Der GNSS-Zaehler des Geraets loest auf
einen Meter auf, kann aber driften und Empfang verlieren.
Deshalb keine Ersetzung, sondern eine Pruefung: die feine Zahl gilt, solange
sie innerhalb der Rundungsunschaerfe des groben Ankers liegt
(GNSS_TOLERANZ_KM). Liegt sie darueber, hat der Anker recht und die feine
Zahl wird verworfen - dann fehlte Empfang, oder der Zaehler ist gedriftet.
Laeuft nur auf Fahrten mit km_quelle == \"odometer\", also genau einmal je
Fahrt: danach steht dort \"odometer+gnss\" (oder es blieb bei \"odometer\",
wenn nichts Brauchbares vorlag, und ein spaeterer Lauf versucht es erneut,
sobald mehr Verlauf da ist)."""
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
grob = fahrt.get("distance_km")
if start is None or ende is None or grob is None:
return
punkte = await verlauf_lesen(k.hass, gnss_sensor, start, ende)
fein = strecke_aus_zaehler(punkte)
if fein is None:
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"}
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,
)
async def _position_screenen(
k: Koordinator, lat_sensor: str, lon_sensor: str, fahrt: dict
) -> None:
@@ -272,6 +272,51 @@ def route_aus_verlauf(
return punkte if len(punkte) >= 2 else None
# Wie weit die GNSS-Strecke vom Kilometerstand des Fahrzeugs abweichen darf.
#
# Beide Enden des Kilometerstands sind auf ganze Kilometer gerundet (am
# echten Fahrzeug nachgemessen: 147 Schritte von exakt 1,0 km, kein einziger
# feinerer). Die wahre Strecke liegt damit irgendwo in einem Fenster von
# +/- 1 km um die Differenz. Was darin liegt, ist mit dem Fahrzeugwert
# vereinbar; was darueber hinausgeht, ist es nicht - dann stimmt etwas nicht,
# und der Fahrzeugwert behaelt recht.
GNSS_TOLERANZ_KM = 1.0
def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
"""Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.
Summiert die Zuwaechse statt einfach Ende minus Anfang zu rechnen, weil der
Zaehler zurueckspringen kann. Ein Ruecksprung ist kein Rueckwaertsfahren,
sondern eine Ruecksetzung - der neue Wert ist dann selbst der Zuwachs seit
der Ruecksetzung, alles davor wurde bereits gezaehlt.
Am 01.09.2026 an zwei echten Fahrten gemessen:
* Fahrt 1, mit Ruecksetzung mitten in der Fahrt (das Geraet uebernahm
seinen konfigurierten Startwert 0, als der CAN-Kilometerstand zum ersten
Mal hochzaehlte): 0,415 km vor der Ruecksetzung, 6,543 km danach - macht
6,958 km. Google Maps sagt 7,1 km fuer dieselbe Strecke. Ohne die
Ruecksprungbehandlung waeren es 6,543 km gewesen, also 0,4 km zu wenig.
* Fahrt 2, ohne Ruecksetzung: 6,816 km. Dort degeneriert die Summe zu
Ende minus Anfang, wie es sein soll.
Bewusst NICHT ueber die Teilstrecken je Datensatz (segment_mileage), die
dasselbe Geraet mitliefert: die verlieren einzelne Datensaetze. In Fahrt 2
waren 4 von 50 Teilstrecken null, obwohl gefahren wurde, und die Summe lag
dadurch 0,2 km zu niedrig. Der Zaehler zaehlt ueber solche Aussetzer hinweg.
None, wenn weniger als zwei Zahlenwerte vorliegen."""
werte = [zahl(wert) for _ts, wert in punkte]
werte = [w for w in werte if w is not None]
if len(werte) < 2:
return None
strecke = 0.0
for vorher, jetzt in zip(werte, werte[1:]):
strecke += jetzt if jetzt < vorher else jetzt - vorher
return round(strecke, 3)
def hoechstwert_im_fenster(
verlauf: list[Verlaufspunkt],
start: datetime.datetime,