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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user