Geraetezeit statt Ankunftszeit - die Wurzel hinter der 3-km-Fahrt (2026.9.4.1)

Das Fahrtfenster stand seit dem 01.09. in der Gerätezeit, der Verlauf war nach
Ankunftszeit sortiert. Zwei Uhren, und das Gerät puffert: was verspätet ankam,
fiel aus einem Fenster heraus, in dem es der Sache nach lag. Am 03.09. hat das
eine Fahrt gekostet - 26 von 48 GPS-Punkten und 3,0 statt 7 km.

verlauf_lesen() stempelt jeden Punkt jetzt mit der Gerätezeit seines
Datensatzes. Eine Stelle, an der alle Verbraucher vorbeikommen. Dazu zwei
Riegel aus derselben Untersuchung: ein Rückschritt von 43 Metern ist keine
Zählerrücksetzung mehr (aus 87 Metern wurden vorher 13,7 km), und das
Fahrtende wird auf das letzte Lebenszeichen des Geräts geklemmt - der Nachlauf
ist eine Rechnung, kein Messwert.

Am echten Recorder nachgerechnet: Tacho 3,0 -> 7,0 km, GNSS 2,952 -> 6,877 km,
Route 26 -> 47 Punkte, Ende 23:21:57 -> 23:17:48. Beim ersten Lauf haben zwei
Fahrten eine Strecke bekommen, die vorher gar keine hatten.

Nicht das Gerät war schuld: es hat lückenlos aufgezeichnet. Die zehn Minuten
Funkstille waren eine offene, aber tote TCP-Sitzung (flespi-Log: 703 s, 16
Nachrichten) - kein Funkloch. Begründung und die Empfehlung fürs Gerät stehen
in AGENTS.md, Abschnitt CB.

Dazu die neun Paritätsbefunde, alle Richtung Panel gelöst - darunter ein
unmaskierter Punkt in beiden Codebasen, der aus "vor 3 Tage" ein "vor 3 Tag"
machte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 02:18:12 +02:00
parent 4a6c48e81c
commit 7f386d19ea
21 changed files with 826 additions and 120 deletions
+150 -8
View File
@@ -26,6 +26,7 @@ Event-Loop nichts verloren.
from __future__ import annotations
import bisect
import datetime
import logging
@@ -39,12 +40,115 @@ _LOGGER = logging.getLogger(__name__)
Verlaufspunkt = tuple[datetime.datetime, str]
# ---------------------------------------------------------------------------
# Gerätezeit statt Ankunftszeit
#
# Der recorder stempelt jeden Zustand mit `last_updated` - dem Zeitpunkt, zu
# dem Home Assistant ihn VERARBEITET hat. Die Fahrtgrenzen dagegen stehen seit
# dem 01.09.2026 in der GERÄTEZEIT (MELDEZEIT_SENSOR, siehe geraetezeit()).
# Zwei Uhren, und das Gerät puffert: dann fällt alles, was verspätet ankommt,
# aus einem Fenster heraus, in dem es der Sache nach liegt.
#
# Am 03.09.2026 an der Fahrt Eichstätt - Adelschlag gemessen. Das Gerät hat
# lückenlos aufgezeichnet (48 GPS-Punkte, größte Lücke 30 s, 6,88 km), aber ab
# Gerätezeit 23:14:13 nichts mehr abgeliefert - erst um 23:24:52 kam der ganze
# Stau, bis zu 641 s verspätet. Gespeichert wurden davon 26 von 48 Punkten und
# 3,0 statt 7 km, weil das Fenster nach Ankunftszeit schnitt. Dazu kam der
# Rückstau in verkehrter Reihenfolge (der aktuelle Datensatz zuerst, dann der
# Puffer ab dem ältesten), was strecke_aus_zaehler() als Zählerrücksetzung las
# und 9,8 km statt 2,6 km ergab.
#
# Der Riegel sitzt hier, an der einen Stelle, an der alle Verbraucher
# vorbeikommen: wert_bei(), wert_ab(), zaehlerstrecke(), route_aus_verlauf(),
# hoechstwert_im_fenster(), naechster_wert(). Damit rechnen Fenster und Werte
# in derselben Uhr, und die Reihenfolge stimmt wieder.
#
# Der Rückstand während der Fahrt lag am 02.09. bei 4 s im Median, am 03.09.
# abends bei 83 s - das ist kein Ausnahmefall, sondern der Normalbetrieb eines
# puffernden Geräts.
NACHREICHFENSTER = datetime.timedelta(hours=6)
# Wie dicht die Meldezeit am Wert desselben Datensatzes liegen muss.
#
# Die flespi-Integration schreibt die Entitäten eines Datensatzes innerhalb
# weniger Millisekunden und den Zeitstempel zuletzt (am 03.09.2026 an den
# Rohdaten nachgesehen). Eine Sekunde ist dafür reichlich - und eng genug, dass
# ein Zustand einer FREMDEN Integration, der zufällig danebenliegt, nicht die
# Gerätezeit des Dongles aufgedrückt bekommt: er behält dann seine
# Ankunftszeit.
ZUORDNUNG_MAX_S = 1.0
def _marken_aus(zustaende, jetzt: datetime.datetime) -> list[tuple[datetime.datetime, datetime.datetime]]:
"""(Ankunftszeit, Gerätezeit) je Datensatz, aufsteigend nach Ankunft.
Unplausible Zeitstempel fallen hier schon heraus (geraetezeit_plausibel) -
eine 0 im Meldezeit-Sensor ergäbe sonst 1970 und zöge jeden Wert, der zu
diesem Datensatz gehört, mit dorthin."""
marken: list[tuple[datetime.datetime, datetime.datetime]] = []
for zustand in zustaende or []:
if zustand.state in ("unknown", "unavailable", None, ""):
continue
try:
gemeldet = datetime.datetime.fromtimestamp(float(zustand.state), datetime.UTC)
except (TypeError, ValueError, OSError, OverflowError):
continue
if not geraetezeit_plausibel(gemeldet, jetzt):
continue
marken.append((zustand.last_updated, gemeldet))
marken.sort(key=lambda m: m[0])
return marken
def geraetezeit_zuordnen(
punkte: list[Verlaufspunkt],
marken: list[tuple[datetime.datetime, datetime.datetime]],
max_abstand_s: float = ZUORDNUNG_MAX_S,
) -> tuple[list[Verlaufspunkt], int]:
"""Stempelt jeden Verlaufspunkt mit der Gerätezeit SEINES Datensatzes um.
Gesucht wird die Marke, die am oder direkt nach dem Punkt liegt - denn die
Integration schreibt den Zeitstempel zuletzt. Erst wenn dort nichts in
Reichweite ist, zählt die davor. Findet sich gar keine, behält der Punkt
seine Ankunftszeit; die Zahl solcher Punkte kommt als zweiter Rückgabewert
zurück, damit der Aufrufer es merken kann.
Reine Funktion auf Listen, damit sie ohne Home Assistant prüfbar ist."""
if not marken:
return list(punkte), len(punkte)
ankunft = [m[0] for m in marken]
neu: list[Verlaufspunkt] = []
ohne = 0
for ts, wert in punkte:
i = bisect.bisect_left(ankunft, ts)
gemeldet: datetime.datetime | None = None
if i < len(marken) and (marken[i][0] - ts).total_seconds() <= max_abstand_s:
gemeldet = marken[i][1]
elif i > 0 and (ts - marken[i - 1][0]).total_seconds() <= max_abstand_s:
gemeldet = marken[i - 1][1]
if gemeldet is None:
ohne += 1
neu.append((ts, wert))
else:
neu.append((gemeldet, wert))
neu.sort(key=lambda p: p[0])
return neu, ohne
def _rohverlauf(
hass: HomeAssistant, entity_id: str, start: datetime.datetime, ende: datetime.datetime
hass: HomeAssistant,
entity_id: str,
start: datetime.datetime,
ende: datetime.datetime,
meldezeit_id: str | None = None,
) -> list[Verlaufspunkt]:
roh = history.get_significant_states(
hass, start, ende, [entity_id], None, True, False
)
umstempeln = bool(meldezeit_id) and meldezeit_id != entity_id
ids = [entity_id, meldezeit_id] if umstempeln else [entity_id]
# Nachgereichte Datensätze kommen später an, als sie entstanden sind - der
# Lesebereich muss also nach hinten reichen, sonst fehlen genau die, um die
# es geht.
bis = ende + NACHREICHFENSTER if umstempeln else ende
roh = history.get_significant_states(hass, start, bis, ids, None, True, False) # type: ignore[arg-type]
ergebnis: list[Verlaufspunkt] = []
for zustand in roh.get(entity_id) or []:
# "unknown"/"unavailable" bedeuten "keine Meldung", nicht "Wert 0" -
@@ -54,7 +158,19 @@ def _rohverlauf(
continue
ergebnis.append((zustand.last_updated, zustand.state))
ergebnis.sort(key=lambda p: p[0])
return ergebnis
if not umstempeln:
return ergebnis
marken = _marken_aus(roh.get(meldezeit_id), datetime.datetime.now(datetime.UTC))
ergebnis, ohne = geraetezeit_zuordnen(ergebnis, marken)
if ohne:
_LOGGER.debug(
"%s: %d von %d Werten ohne Meldezeit in Reichweite - sie behalten "
"ihre Ankunftszeit", entity_id, ohne, len(ergebnis),
)
# Was jetzt hinter dem Fenster liegt, gehört auch nicht hinein. Der Rand am
# Anfang bleibt: der Wert VOR dem Fenster ist der Anker (siehe wert_bei()).
return [p for p in ergebnis if p[0] <= ende]
async def verlauf_lesen(
@@ -62,15 +178,22 @@ async def verlauf_lesen(
entity_id: str | None,
start: datetime.datetime,
ende: datetime.datetime,
meldezeit_id: str | None = None,
) -> list[Verlaufspunkt]:
"""Zustandsverlauf einer Entität als aufsteigende Liste von
(Zeitpunkt, Rohwert). Leere Liste, wenn die Entität nicht zugeordnet ist,
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist."""
im Zeitraum nichts vorliegt oder der recorder nicht erreichbar ist.
Mit `meldezeit_id` tragen die Punkte die **Gerätezeit** statt der
Ankunftszeit - siehe NACHREICHFENSTER, warum das der Normalfall sein muss.
Zu setzen ist sie nur für Entitäten DIESES Geräts; für Sensoren einer
anderen Integration wäre sie falsch, und ZUORDNUNG_MAX_S fängt den Fall
zusätzlich ab."""
if not entity_id:
return []
try:
return await get_instance(hass).async_add_executor_job(
_rohverlauf, hass, entity_id, start, ende
_rohverlauf, hass, entity_id, start, ende, meldezeit_id
)
except Exception: # noqa: BLE001 - der Verlauf ist Beiwerk, nie der Kern
# Ohne Verlauf bleibt eine Fahrt ohne Strecke bzw. der Import leer -
@@ -448,6 +571,22 @@ def ist_gefahren(distanz: float | None) -> bool:
return distanz is None or round(distanz, 1) != 0
# Ab wann ein Rueckwaertsschritt eine Ruecksetzung ist und kein Rauschen.
#
# Eine echte Ruecksetzung faellt auf null: am 01.09.2026 sprang der Zaehler
# mitten in der Fahrt von 0,415 auf 0, als das Geraet seinen konfigurierten
# Startwert uebernahm. Ein Rueckschritt von 6,891 auf 6,848 km ist etwas ganz
# anderes - er entsteht, wenn zwei Datensaetze in derselben Millisekunde
# ankommen und ihre Reihenfolge dadurch nicht mehr eindeutig ist (am
# 03.09.2026 in der Nachlieferung real vorgekommen). Ihn als Ruecksetzung zu
# lesen hiesse, den ganzen Zaehlerstand noch einmal als Strecke zu buchen:
# aus 87 Metern wurden so 13,7 km.
#
# Die beiden Faelle liegen weit auseinander (Verhaeltnis 0,00 gegen 0,99), die
# Grenze in der Mitte ist deshalb unkritisch. Ein kleinerer Rueckschritt zaehlt
# gar nicht - weder vorwaerts noch als Ruecksetzung.
RUECKSETZ_ANTEIL = 0.5
def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
"""Gefahrene Strecke aus einem laufenden Kilometerzaehler des Geraets.
@@ -478,7 +617,10 @@ def strecke_aus_zaehler(punkte: list[Verlaufspunkt]) -> float | None:
return None
strecke = 0.0
for vorher, jetzt in zip(werte, werte[1:]):
strecke += jetzt if jetzt < vorher else jetzt - vorher
if jetzt >= vorher:
strecke += jetzt - vorher
elif jetzt < vorher * RUECKSETZ_ANTEIL:
strecke += jetzt
return round(strecke, 3)