Fahrten: Verbrauch-Näherung, 0V-Zustandsanzeige gefiltert, Plausibilitätsgrenze für die Live-Vervollständigung

Verbrauch (l/100km) war bislang totes Schema - kein Codepfad füllte
verbrauch_l_100km je. Jetzt aus der Literstand-Differenz (TANK_LITER_SENSOR)
über die Distanz genähert, live und beim Import, in beiden Frontends.

Die "Zustand"-Kachel zeigte die Batteriespannung ungefiltert direkt vom
Sensor, unabhängig von der Plausibilitätsgrenze der Verlaufsaufzeichnung -
ein Sensorausreißer (0V) zeigte sich dort weiterhin, obwohl die Messwertliste
ihn längst verwarf. Dieselbe Grenze gilt jetzt auch für diesen Anzeigepfad.

Reale Fahrtendaten zeigten eine Fahrt mit 22km in 67s (~1180 km/h) - die
Live-Vervollständigung (screening.py) hatte anders als der Import keine
Plausibilitätsprüfung der Durchschnittsgeschwindigkeit. Jetzt gemeinsam in
verlauf.py (UNPLAUSIBLE_KMH/durchschnitt_kmh) für beide Pfade. Dabei einen
zweiten echten Bug gefunden: _fahrt_screenen() zog sein Ergebnis nie ins
In-Memory-Objekt nach (nur in die Ablage) - eine im selben Durchlauf gerade
erst ermittelte Distanz blieb für spätere Schritte (z. B. Verbrauch) bis zum
nächsten Screening unsichtbar. Beide Stellen jetzt behoben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:01:57 +02:00
parent cf57c5f1a5
commit 80d73e8afd
12 changed files with 344 additions and 30 deletions
+175
View File
@@ -3267,6 +3267,181 @@ measured 736×170px at a 1400px viewport (Y-scale exactly `1`), axis label `getC
`tsc --noEmit` clean. Manifest bumped to `2026.8.27.5`, `npm run ota` rerun, `audi_ha_test` restart confirmed
clean via log.
**Fourth follow-up (2026.8.27.6): the real gap behind the `.5` "0V on the real instance" report - a second,
unfiltered publish path, not (only) stale/not-yet-updated data.** Owner report: "Mein Audi/Zustand Box"
still shows `Batteriespannung 0,0V`, while the Batterie-Messwerte list has no such entry. Traced to a second
code path that was never touched by the `SPANNUNG_MIN_V` floor at all: `veroeffentlichung.py`'s
`fahrzeugstatus()` publishes `batteriespannung` as the **raw, unfiltered current `BATTERIE_SENSOR` reading**
(`_zu_zahl(zustand_oder_none(...))`), completely independent of `batterie.py`'s history-recording path that
the floor actually guards. The panel's "Zustand" tile (`audi-dashboard-app.js` ~line 1611) and
companion-app's `MeinAudi.tsx`/`Batterie.tsx` "Aktuell gemeldet" line all read this same published field
directly - so a live sensor glitch (confirmed live: the test container's `BATTERIE_SENSOR` was genuinely
reporting raw state `"0"`) always showed through unfiltered there, in both codebases, regardless of whether
the instance had ever been updated. Fixed by applying the identical floor to this publish too: `spannung =
None` when the raw reading is below `SPANNUNG_MIN_V`, so "Zustand"/`Aktuell gemeldet` now show "unbekannt"/
"—" for the same implausible readings the history path already silently drops - one backend fix covers both
frontends, since both read the same published field. Needed a **function-local** import of `SPANNUNG_MIN_V`
from `batterie.py` (not a module-level one): `batterie.py` already imports `zustand_oder_none` from
`veroeffentlichung.py`, so a top-level import the other direction risks a circular-import failure depending
on load order (batterie.py reaches its own `from .veroeffentlichung import zustand_oder_none` line *before*
its `SPANNUNG_MIN_V = 10.0` assignment, so an eager reverse import can hit a partially-initialized module).
A local import inside `fahrzeugstatus()` sidesteps this entirely - both modules are always fully loaded by
the time that function actually runs. Verified live: a real Docker Desktop outage interrupted this fix
mid-session (confirmed via `docker ps` failing, unrelated to the code) - resumed once Docker was back.
`py_compile` clean, `audi_ha_test` restart confirmed clean via log **with no circular-import traceback**
(the actual proof this pattern needed, `py_compile` alone can't catch an import-order bug), and live in the
browser: raw sensor confirmed still reporting `"0"` via a direct state query, "Zustand" tile now reads
"Batteriespannung unbekannt" instead of "0,0 V". No companion-app source changes this round (backend-only
fix), so no OTA rebuild needed. Manifest bumped to `2026.8.27.6`.
## V. Per-trip Verbrauch (l/100 km): dead schema closed with a liter-based approximation, real-sensor
question left open (2026.8.27.7)
**The trip detail's "Verbrauch" row (`fahrtVerbrauch()`, `audi-dashboard-app.js`) always showed "liegt nicht
vor" - traced (owner asked "why is it so? what sensor are you waiting for?") to `t.verbrauch_l_100km` being
dead schema exactly like `start_lat`/`route` were before section U: it existed **only** in the frontend, no
backend code path (live or import) had ever written it, and no Setup-mappable sensor role existed for it
either. The "vom Fahrzeug gemeldet" caption was aspirational leftover, not a description of anything actually
wired up.**
**Owner's decision, to close this properly rather than just deleting the dead row:** two options exist -
(a) the FMM003 might expose a genuine short-trip l/100km value directly over OBD (the owner is checking), or
(b) compute an approximation from `TANK_LITER_SENSOR`'s liter-delta over the trip ÷ distance. Built (b) now
as the working default, with (a) tracked as an explicit follow-up: **if a real short-trip consumption sensor
turns up, swap to it directly instead of this approximation** - noted here rather than only in a chat
message, per this file's own "state changes, note it" maintenance rule.
**Implementation**, mirroring the existing `route_aus_verlauf()`/position-screening pattern from section U
for the exact same reason (live vs. import parity, no duplicated math): new
`verbrauch_aus_literstaenden(start_liter, end_liter, distance_km)` in `verlauf.py` - `None` unless both liter
readings and a positive distance are available *and* the liter level actually fell (a level that rose or held
steady means a mid-trip refuel or noise, not a meaningful ratio). `verbrauch_l_100km` added to the trip schema
(`leere_fahrt()`, was entirely absent before). Live path: new `_verbrauch_screenen()` in `screening.py`,
wired into `durchfuehren()` exactly like `_position_screenen()` - runs whenever `TANK_LITER_SENSOR` is mapped,
re-attempted on every screening pass until it succeeds (liter data can lag just like odometer/GPS data).
Import path: `_fahrten_importieren()` in `historienimport.py` computes it inline from the already-fetched
`verlaeufe["tank_liter"]` history (was fetched for tank-refill import already, just never used for this).
Both frontends: panel's caption changed from the false "vom Fahrzeug gemeldet" to "Näherung aus
Tankfüllstand"; companion-app's `FahrtDetail.tsx` had **no Verbrauch row at all before this** (a pre-existing
parity gap, not something this change broke) - added one in the same position as the panel's (after
"Durchschnitt"), same honest caption, `verbrauch_l_100km` added to the `Fahrt` type (was typed `unknown`
nowhere - simply absent).
**A second, real bug found and fixed while live-testing this: `_fahrt_screenen()` (the existing odometer
screening, unchanged by this feature) never synced its result back onto the in-memory `fahrt` dict, only into
storage.** `_vollstaendig()` only ever wrote `distance_km`/`km_quelle`/`status` into a local `aenderungen`
dict, which `_fahrt_screenen()` then persisted via `fahrt_aktualisieren()` - but never applied to the `fahrt`
object itself, unlike `_position_screenen()`, which already does `fahrt.update(aenderungen)` for exactly this
reason. Consequence: when a trip's distance was computed for the first time and the *same* `durchfuehren()`
pass then reached the new `_verbrauch_screenen()` step, the `ohne_verbrauch` filter still saw the stale
`distance_km: None` from the `fahrten` list fetched at the top of the function - `verbrauch_l_100km` silently
stayed unfilled for one whole screening cycle, only catching up on the *next* trigger. Fixed by adding the
same `fahrt.update(aenderungen)` to `_fahrt_screenen()`, matching `_position_screenen()`'s already-correct
pattern - found by reproducing it directly (a trip's `distance_km` and `verbrauch_l_100km` both stayed empty
after the first screening pass, filled in only after a second explicit trigger) rather than by reading the
code and guessing.
Verified: `py_compile` clean on all five touched backend files (incl. this `screening.py` fix), `node --check`
clean on the panel JS, companion-app `tsc --noEmit` clean and full suite green at 146/146. Live end-to-end in
`audi_ha_test` (`TANK_LITER_SENSOR` was already mapped to the container's real
`sensor.testcar_b9_fmm003_testintegratoin_can_fuel_volume`, liters - no Setup change needed): ran a synthetic
20.0→19.0 l trip over a simulated 10 km, confirmed `verbrauch_l_100km: 10.0` computed correctly and rendered
on the panel's trip detail ("10,0 l/100 km · Näherung aus Tankfüllstand", screenshot taken). companion-app's
`FahrtDetail` row was **not** live-rendered this round (no running dev server connected to a backend this
session) - verified only via `tsc --noEmit` and the full test suite, same as several other companion-app-only
changes this session.
**Separately, while pulling data for this: found the container's `ZUENDUNG_SENSOR` mapping had changed since
last checked** - now `binary_sensor...engine_ignition_or_acc_status` (stable, no chattering observed) instead
of the `instant_movement_status` entity used earlier this session, which **was** flapping on/off dozens of
times per minute (test-integration noise, unrelated to any real vehicle behavior). The owner had reconfigured
the Setup mapping themselves in the meantime. Confirms this container's synthetic test entities remain
unrelated to the owner's real-vehicle trip-merging report from the same day (section P/the "no clean
ignition-off signal" conversation) - it needs the real instance's own history, not this container's.
Manifest bumped to `2026.8.27.7`, `npm run ota` rerun (companion-app source changed).
## W. Correction: `audi_ha_test` is connected to the real vehicle, not synthetic data - and the real
trip-merging/GPS-jump report, root-caused with real data (2026.8.27.8)
**Correction to sections U and V above: the owner confirmed `audi_ha_test` (this session's "test container")
receives live telemetry from the real car/real FMM003 - the same sensors are wired into both this container
and the real HA instance in parallel.** Both prior sections state the opposite ("synthetic test-integration
noise", "unrelated to the owner's real-vehicle... report") - that was wrong, and this project's own
convention is to correct rather than silently rewrite history, so it stays here rather than editing U/V after
the fact. The erratic on/off chatter observed on `instant_movement_status` earlier the same day is real
telemetry from a real accelerometer/movement-based signal, not fabricated test noise - see below for what it
actually means. **The owner also confirmed creating synthetic test trips in this container (as done
throughout sections R-V) remains fine** - it's a secondary instance fed by the same sensors, not the sole
copy of their trip history, so the established "simulate via `hass.callApi` POST states, verify, clean up
afterward" testing convention stands unchanged going forward.
**With that corrected, the real trip-merging/GPS-jump report (originally raised in section P's conversation)
was root-caused directly from this container's real recorder history - no separate pull from a "production"
instance was needed after all, since this *is* that instance's real data.** Matched the owner's report
exactly once UTC/local (`CEST = UTC+2`) is accounted for:
- `t-da363d681915` (`09:55:31``12:05:01` UTC = **11:5514:05 local**) - the wrongly-merged trip.
- `t-8be105166025` (`12:28:05``12:29:12` UTC = **14:2814:29 local**), `distance_km: 22.0` over 67 seconds
(~1180 km/h) - the "GPS jumps 22 km" report.
**Root cause of the merge, confirmed from the raw history (not the "unavailable-instead-of-off" hypothesis
originally guessed in section P's conversation):** `instant_movement_status` (mapped as `ZUENDUNG_SENSOR` at
the time) reported sustained, unbroken `on` for 4564-minute stretches with zero transitions - including
straight through the ~30-minute real stop the owner described around `10:22` UTC, with no `off` at all during
it. Where it did flip `off`, it almost always flipped back `on` within milliseconds to a couple of seconds,
so the 15-minute pause-close logic (`fahrterkennung.py`) never got an uncanceled window to actually end the
trip - every near-instant `on` cancels the pending close (`k.warte_ende_ab_abbrechen()`, correct behavior in
isolation) before the wait can complete. No `unavailable` states appeared anywhere in the relevant window, so
the section P dropout-hypothesis genuinely doesn't apply to this specific incident. The entity itself is the
problem: a real-time movement/accelerometer heuristic, not a debounced ACC/ignition state - it can both
chatter during genuine driving *and* fail to register a genuine stop. **The owner had already fixed this
independently by remapping `ZUENDUNG_SENSOR` to `engine_ignition_or_acc_status`** (confirmed stable, no
chatter, when checked) - the correct fix; nothing further needed there from this end.
**The GPS-jump trip (`t-8be105166025`) is a second, related but distinct defect: the live trip-completion
path had no plausibility guard on computed average speed at all.** `historienimport.py` already had one
(`UNPLAUSIBLE_KMH = 300`, added 2026-08-25/section P) for exactly this failure mode - a real 22 km drive
whose *actual* duration was much longer than 67 seconds, but whose recorded trip window got compressed by the
chattering ignition entity creating a spuriously short boundary, while `naechster_wert()`'s odometer lookup
still (correctly, on its own terms) found the real km delta nearest those wrong timestamps. `screening.py`
(the live path) never got the equivalent guard - a gap already flagged as open in section P but not built
until now. Fixed by moving the check into a shared home: `UNPLAUSIBLE_KMH` and a new `durchschnitt_kmh()`
helper now live in `verlauf.py` (both `screening.py` and `historienimport.py` import them, no more duplicated
constant), and `screening.py`'s `_vollstaendig()` - the single choke point both `_fahrt_screenen()` and
`_lueckenlos_verketten()` already funnel through - now rejects (not just the distance, both `odo_start` *and*
`odo_end`) whenever the implied average speed exceeds the limit, logs a warning, and leaves the trip `offen`
to be retried on the next screening pass, exactly matching how `historienimport.py` already handles this
class of error.
**The two already-broken real trip records were deliberately left untouched** - they're the owner's real
driving history, not disposable test data (see the correction above), so fixing/deleting them is the owner's
call via the app's own edit/delete controls, not something to do unilaterally from this session.
**Owner then confirmed synthetic trips in this container remain fine** (same sensors feed both this
container and the real instance in parallel, this one is not the sole copy of trip history) **- and drew a
hard line that stays for the rest of this project: `audi_ha_test`/`:18123` is the only instance ever to be
touched; the real instance is never to be worked on, full stop.** Attempted a live end-to-end repro of the
exact bug (POSTed a fake +22 km jump over ~8 s, same recipe used successfully for the position/verbrauch
features earlier this session) - and hit a new, worth-remembering limitation: **the real FMM003/flespi
integration re-asserts its own live odometer reading shortly after a manual `POST /api/states` write**, so
the simulated +22 km value never actually landed in the recorder at all (confirmed via a direct history
query - only the real, unchanged ~21324 km value ever appears). This container's earlier "first screening
pass runs before the recorder commits" races (sections R/S) may have partly been this same effect rather than
pure commit latency - a real competing writer, not just replay timing. **Consequence for future sessions:**
simulating a *specific* sensor value in this container is unreliable for entities the real FMM003 actively
reports (ignition, odometer, GPS, tank) - it can work if the value is posted right before the dependent
event fires (as it did for the position/verbrauch tests), but don't assume a posted value will still be there
moments later if the real device happens to report in the meantime.
Fell back to the same no-live-state verification as the first pass: `py_compile` clean on all three touched
backend files, `audi_ha_test` restart confirmed clean via log (single `audi_dashboard` loader warning, no
duplicate-domain regression), and the plausibility math verified by calling `durchschnitt_kmh()` directly
against the real bug's own numbers (no HA state involved): `durchschnitt_kmh(22.0, 67)``1182.1` km/h,
correctly exceeding `UNPLAUSIBLE_KMH=300` and therefore now rejected; a real plausible drive
(`durchschnitt_kmh(22.0, 1200)``66.0` km/h) correctly passes through unaffected. No companion-app source
changes this round (backend-only). Manifest bumped to `2026.8.27.8`.
---
## Working conventions (observed — keep them)
+4
View File
@@ -56,6 +56,10 @@ export interface Fahrt {
start_address?: string | null;
end_address?: string | null;
route?: [number, number][] | null;
/** Näherung aus der Literstand-Differenz (TANK_LITER_SENSOR) ÷ Distanz, kein
* vom Fahrzeug selbst für diese eine Fahrt gemeldeter Wert - siehe
* verbrauch_aus_literstaenden() im Backend. */
verbrauch_l_100km?: number | null;
pausen?: unknown[];
}
@@ -90,6 +90,11 @@ export function FahrtDetail({ id }: { id: string | undefined }) {
: "—"
}
/>
<Wertzeile
label="Verbrauch"
wert={fahrt.verbrauch_l_100km != null ? `${de(fahrt.verbrauch_l_100km, 1)} l/100 km` : "liegt nicht vor"}
zusatz="Näherung aus Tankfüllstand"
/>
</>
}
/>
@@ -52,6 +52,7 @@ def leere_fahrt(start_ts: datetime.datetime, ende_ts: datetime.datetime, quelle:
"odo_start": None,
"odo_end": None,
"avg_speed_kmh": None,
"verbrauch_l_100km": None,
"start_lat": None,
"start_lon": None,
"end_lat": None,
@@ -1 +1 @@
{"version":"2026.8.27.5","sha256":"8d63b052bdcf4cf2eabd5a01f525fc25c4ab8eea30327dc3b8cbd0db2feb05ed","bytes":235510,"gebaut":"2026-08-27T16:30:48Z"}
{"version":"2026.8.27.7","sha256":"4631011c09be09efcdf787ba86ad1bd1fb40006f0d247e8b99b29b0a196f2da3","bytes":235579,"gebaut":"2026-08-27T17:32:23Z"}
@@ -3165,7 +3165,7 @@ function vTrip(id) {
<div class="row"><dt>Startkilometer</dt><dd>${t.odo_start != null ? de(t.odo_start) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Endkilometer</dt><dd>${t.odo_end != null ? de(t.odo_end) + " km" : "noch offen"}</dd></div>
<div class="row"><dt>Dauer</dt><dd>${dauerText(t.duration_s)}</dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>vom Fahrzeug gemeldet</small></dd></div>
<div class="row"><dt>Verbrauch</dt><dd>${fahrtVerbrauch(t) != null ? de(fahrtVerbrauch(t), 1) + " l/100 km" : "liegt nicht vor"}<small>Näherung aus Tankfüllstand</small></dd></div>
</dl>
<button class="aktion" data-fahrtbearbeiten>Werte bearbeiten</button>
</div>
@@ -44,7 +44,16 @@ from typing import TYPE_CHECKING
from .batterie import SPANNUNG_MIN_V
from .fahrterkennung import leere_fahrt, pausenzeit_sekunden
from .tankerkennung import LITER_SCHWELLE, leerer_tankvorgang, schwelle_prozent
from .verlauf import Verlaufspunkt, route_aus_verlauf, verlauf_lesen, wert_bei, zahl
from .verlauf import (
UNPLAUSIBLE_KMH,
Verlaufspunkt,
durchschnitt_kmh,
route_aus_verlauf,
verbrauch_aus_literstaenden,
verlauf_lesen,
wert_bei,
zahl,
)
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -61,14 +70,11 @@ TANK_DUBLETTE_MIN = 90
# fluten sie den Bestand aber mit Nulleinträgen. Bewusst konservativ.
MINDESTDAUER_S = 60
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
# wert_bei()-Treffer über eine Lücke hinweg). Die Strecke wird dann verworfen
# statt eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt
# "offen", genau wie bei fehlendem Kilometerstand.
UNPLAUSIBLE_KMH = 300
# UNPLAUSIBLE_KMH selbst lebt in verlauf.py - gemeinsam mit screening.py, das
# dieselbe Prüfung seit 2026.8.27 auch für die Live-Vervollständigung nutzt
# (siehe dortiges _vollstaendig()). Die Strecke wird dann verworfen statt
# eine physikalisch unmögliche Fahrt anzuzeigen - die Fahrt bleibt "offen",
# genau wie bei fehlendem Kilometerstand.
def als_zeit(wert: object) -> datetime.datetime | None:
@@ -150,6 +156,7 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
km_verlauf = verlaeufe["km"]
lat_verlauf = verlaeufe["lat"]
lon_verlauf = verlaeufe["lon"]
tank_liter_verlauf = verlaeufe["tank_liter"]
angelegt = uebersprungen = zu_kurz = 0
neue: list[dict] = []
@@ -169,19 +176,17 @@ 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)
durchschnitt = None
if distanz is not None and dauer_s > 0:
durchschnitt = round(distanz / (dauer_s / 3600.0), 1)
if durchschnitt > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
)
distanz = None
odo_start = odo_end = None
durchschnitt = None
durchschnitt = durchschnitt_kmh(distanz, dauer_s)
if durchschnitt is not None and durchschnitt > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Kilometerstand für Fahrt %s bis %s verworfen: unmögliche %s km/h "
"im Schnitt (%s km in %s s) - vermutlich ein Ausreißer im Verlauf. "
"Die Fahrt wird trotzdem angelegt, bleibt aber ohne Strecke.",
f_start.isoformat(), f_ende.isoformat(), durchschnitt, distanz, dauer_s,
)
distanz = None
odo_start = odo_end = None
durchschnitt = None
fahrt = leere_fahrt(f_start, f_ende, "import")
fahrt.update({
@@ -195,6 +200,9 @@ async def _fahrten_importieren(k: Koordinator, verlaeufe: dict) -> dict:
"end_lat": wert_bei(lat_verlauf, f_ende),
"end_lon": wert_bei(lon_verlauf, f_ende),
"route": route_aus_verlauf(lat_verlauf, lon_verlauf, f_start, f_ende),
"verbrauch_l_100km": verbrauch_aus_literstaenden(
wert_bei(tank_liter_verlauf, f_start), wert_bei(tank_liter_verlauf, f_ende), distanz
),
"status": "vollständig" if distanz is not None else "offen",
})
neue.append(fahrt)
@@ -1,7 +1,7 @@
{
"domain": "audi_dashboard",
"name": "Audi Dashboard",
"version": "2026.8.27.5",
"version": "2026.8.27.8",
"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"],
+72 -4
View File
@@ -22,7 +22,14 @@ import datetime
import logging
from typing import TYPE_CHECKING
from .verlauf import naechster_wert, route_aus_verlauf, verlauf_lesen
from .verlauf import (
UNPLAUSIBLE_KMH,
durchschnitt_kmh,
naechster_wert,
route_aus_verlauf,
verbrauch_aus_literstaenden,
verlauf_lesen,
)
if TYPE_CHECKING:
from .koordinator import Koordinator
@@ -47,9 +54,30 @@ def _als_zeit(wert: str | None) -> datetime.datetime | None:
def _vollstaendig(aenderungen: dict, fahrt: dict) -> dict:
"""Ergänzt Strecke, Quelle und Status, sobald beide Kilometerstände da
sind. Eine Fahrt ist genau dann "vollständig", wenn ihre Strecke feststeht
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch"."""
- "offen" heißt nicht "unterwegs", sondern "Daten fehlen noch".
Verwirft eine physikalisch unplausible Durchschnittsgeschwindigkeit
(UNPLAUSIBLE_KMH) statt sie anzuzeigen, statt der bislang unbehandelten
Lücke gegenüber historienimport.py: eine falsch abgegrenzte Fahrt (z. B.
durch ein chatterndes Zündungssignal) kann sonst einen Kilometerstand-
Sprung wie "22 km in 67 s" als echte Strecke zeigen. Sowohl odo_start als
auch odo_end werden dann verworfen (nicht nur die Strecke) - die Fahrt
bleibt "offen" und wird beim nächsten Screening erneut versucht, statt
einen unmöglichen Wert dauerhaft zu speichern."""
if fahrt.get("odo_start") is not None and fahrt.get("odo_end") is not None:
aenderungen["distance_km"] = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
distanz = round(fahrt["odo_end"] - fahrt["odo_start"], 1)
geschwindigkeit = durchschnitt_kmh(distanz, fahrt.get("duration_s"))
if geschwindigkeit is not None and geschwindigkeit > UNPLAUSIBLE_KMH:
_LOGGER.warning(
"Fahrt %s: Kilometerstand verworfen - unmögliche %s km/h im "
"Schnitt (%s km in %s s), vermutlich ein Ausreißer im "
"Verlauf oder eine falsch abgegrenzte Fahrt.",
fahrt.get("trip_id"), geschwindigkeit, distanz, fahrt.get("duration_s"),
)
aenderungen["odo_start"] = None
aenderungen["odo_end"] = None
return aenderungen
aenderungen["distance_km"] = distanz
aenderungen["km_quelle"] = "odometer"
aenderungen["status"] = "vollständig"
return aenderungen
@@ -81,6 +109,15 @@ async def durchfuehren(k: Koordinator) -> None:
for fahrt in ohne_position:
await _position_screenen(k, lat_sensor, lon_sensor, fahrt)
tank_liter_sensor = k.zuordnung.werte.TANK_LITER_SENSOR
if tank_liter_sensor:
ohne_verbrauch = [
f for f in fahrten
if f.get("verbrauch_l_100km") is None and f.get("distance_km")
]
for fahrt in ohne_verbrauch:
await _verbrauch_screenen(k, tank_liter_sensor, fahrt)
await _lueckenlos_verketten(k)
await k.fahrten_veroeffentlichen()
@@ -115,7 +152,15 @@ async def _fahrt_screenen(k: Koordinator, km_sensor: str, fahrt: dict) -> None:
if geaendert:
aenderungen = {"odo_start": fahrt.get("odo_start"), "odo_end": fahrt.get("odo_end")}
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], _vollstaendig(aenderungen, fahrt))
aenderungen = _vollstaendig(aenderungen, fahrt)
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], aenderungen)
# Das In-Memory-Objekt sofort mitziehen (nicht nur die Ablage) - sonst
# sehen spätere Schritte innerhalb desselben durchfuehren()-Laufs
# (z. B. _verbrauch_screenen(), das eine bekannte distance_km
# braucht) noch den alten Stand, obwohl die Strecke gerade erst in
# diesem Durchlauf ermittelt wurde. _position_screenen() macht das
# bereits genauso.
fahrt.update(aenderungen)
async def _position_screenen(
@@ -161,6 +206,29 @@ async def _position_screenen(
fahrt.update(aenderungen)
async def _verbrauch_screenen(
k: Koordinator, tank_liter_sensor: str, fahrt: dict
) -> None:
"""Trägt den Verbrauch (l/100 km) nach, sobald die Distanz feststeht und
der Tankfüllstand (Liter) das hergibt - siehe verbrauch_aus_literstaenden()
für die Näherung selbst und ihre Begrenzung."""
start = _als_zeit(fahrt.get("ts_start"))
ende = _als_zeit(fahrt.get("ts_end"))
if start is None or ende is None:
return
verlauf = await verlauf_lesen(k.hass, tank_liter_sensor, start - SUCHFENSTER, ende + SUCHFENSTER)
if not verlauf:
return
verbrauch = verbrauch_aus_literstaenden(
naechster_wert(start, verlauf), naechster_wert(ende, verlauf), fahrt.get("distance_km")
)
if verbrauch is not None:
await k.ablage.fahrt_aktualisieren(fahrt["trip_id"], {"verbrauch_l_100km": verbrauch})
fahrt["verbrauch_l_100km"] = verbrauch
async def _lueckenlos_verketten(k: Koordinator) -> None:
"""Fahrt-Ende(N) == Fahrt-Start(N+1), solange dazwischen nicht gefahren
wurde.
@@ -85,6 +85,25 @@ def zahl(wert: object) -> float | None:
return None
# Kein Auto dieser Art erreicht diesen Schnitt. Eine errechnete
# Durchschnittsgeschwindigkeit darüber ist kein Beleg für eine schnelle
# Fahrt, sondern für einen fehlerhaften Kilometerstand-Sprung am Fahrtrand
# (z. B. ein Ausreißer im Verlauf oder ein knapp daneben liegender
# wert_bei()/naechster_wert()-Treffer über eine Lücke hinweg). Gemeinsam für
# den Import (historienimport.py) und die Live-Vervollständigung
# (screening.py) - beide dürfen dieselbe Strecke nie unterschiedlich
# bewerten.
UNPLAUSIBLE_KMH = 300
def durchschnitt_kmh(distanz_km: float | None, dauer_s: float | None) -> float | None:
"""km/h aus Strecke und Dauer, oder None wenn eine der beiden fehlt oder
die Dauer nicht positiv ist."""
if distanz_km is None or not dauer_s or dauer_s <= 0:
return None
return round(distanz_km / (dauer_s / 3600.0), 1)
def wert_bei(
verlauf: list[Verlaufspunkt], zeitpunkt: datetime.datetime
) -> float | None:
@@ -134,6 +153,26 @@ def route_aus_verlauf(
return punkte if len(punkte) >= 2 else None
def verbrauch_aus_literstaenden(
start_liter: float | None, end_liter: float | None, distance_km: float | None
) -> float | None:
"""l/100km aus der Differenz zweier Literstände (TANK_LITER_SENSOR) und
der gefahrenen Strecke - eine Näherung, kein vom Fahrzeug selbst für
diese eine Fahrt gemeldeter Wert (ein solcher OBD-Sensor steht als
offener Punkt in AGENTS.md; taucht einer auf, ersetzt er diese Näherung).
None, wenn einer der Werte fehlt, die Strecke nicht positiv ist, oder der
Literstand nicht gefallen ist (z. B. ein Tankvorgang während der Fahrt,
oder unveränderter Füllstand) - in beiden Fällen ist der Quotient nicht
aussagekräftig."""
if start_liter is None or end_liter is None or not distance_km or distance_km <= 0:
return None
verbraucht = start_liter - end_liter
if verbraucht <= 0:
return None
return round(verbraucht / distance_km * 100, 1)
def naechster_wert(
zielzeit: datetime.datetime, verlauf: list[Verlaufspunkt]
) -> float | None:
@@ -149,11 +149,25 @@ def fahrzeugstatus(hass: HomeAssistant, werte: Sensorzuordnung) -> dict:
standort = _standort(hass, werte)
# Verzögerter Import (statt oben am Dateikopf): batterie.py importiert
# seinerseits zustand_oder_none aus diesem Modul - ein Import auf
# Modulebene würde bei bestimmter Ladereihenfolge zu einem
# Circular-Import-Fehler führen. Zum Zeitpunkt dieses Funktionsaufrufs
# sind beide Module immer schon vollständig geladen.
from .batterie import SPANNUNG_MIN_V
spannung = _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR))
if spannung is not None and spannung < SPANNUNG_MIN_V:
# Dieselbe Plausibilitätsgrenze wie beim Aufzeichnen (batterie.py) -
# sonst zeigt die "Zustand"-Kachel einen Wert, der im Messwertverlauf
# nie auftaucht, weil er dort schon verworfen wird.
spannung = None
return {
"km": _zu_zahl(zustand_oder_none(hass, werte.KM_SENSOR)),
"tankprozent": _zu_zahl(zustand_oder_none(hass, werte.TANK_SENSOR)),
"reichweite_km": _zu_zahl(zustand_oder_none(hass, werte.RANGE_SENSOR)),
"batteriespannung": _zu_zahl(zustand_oder_none(hass, werte.BATTERIE_SENSOR)),
"batteriespannung": spannung,
"gesichert": gesichert,
"sicherheitscheck": sicherheitscheck,
# Fährt das Fahrzeug gerade? Kommt aus derselben Zündungs-Entität, die