Skip to content

Commit 90f49f7

Browse files
committed
fix: reject implausible local trip backfill evidence
1 parent 0309587 commit 90f49f7

1 file changed

Lines changed: 77 additions & 1 deletion

File tree

custom_components/sv_dashboard/trip_repair.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,14 @@
1515
_ODOMETER_TOLERANCE_KM = 0.2
1616
_LOCAL_START_TOLERANCE_KM = 1.0
1717
_LOCAL_TIME_TOLERANCE_SECONDS = 20 * 60
18+
_LOCAL_DISTANCE_TOLERANCE_KM = 2.0
1819
_MAX_TRIP_DISTANCE_KM = 1000.0
1920
_MAX_TRIP_DURATION_SECONDS = 24 * 60 * 60
2021
_MAX_TRIP_SPEED_KMH = 300.0
22+
# Local SV observations are used as corroborating/backfill evidence, not as
23+
# canonical truth. Keep this threshold deliberately conservative so a stale
24+
# odometer composite cannot inject energy into an otherwise valid server trip.
25+
_MAX_LOCAL_BACKFILL_SPEED_KMH = 220.0
2126

2227
_REPAIRABLE_SOURCE_FLAGS = {
2328
"missing_or_non_positive_distance",
@@ -66,6 +71,70 @@ def _trip_sort_key(trip: dict[str, Any]) -> tuple[str, str]:
6671
)
6772

6873

74+
def _local_trip_backfill_quality(row: dict[str, Any]) -> tuple[bool, list[str]]:
75+
"""Classify local SV trip evidence before it may feed canonical backfill.
76+
77+
Local rows are assembled from live Home Assistant observations. A stale
78+
odometer update can therefore create a composite distance that is internally
79+
impossible even though the record has otherwise useful SOC values. Such a
80+
row must never be used to backfill canonical server energy.
81+
"""
82+
flags: list[str] = []
83+
distance = _number(row.get("distance_km"))
84+
start_mileage = _number(row.get("start_mileage"))
85+
end_mileage = _number(row.get("end_mileage"))
86+
87+
if distance is None or distance <= 0:
88+
flags.append("local_missing_or_non_positive_distance")
89+
elif distance > _MAX_TRIP_DISTANCE_KM:
90+
flags.append("local_distance_outlier")
91+
92+
if start_mileage is None or start_mileage < 0:
93+
flags.append("local_missing_start_odometer")
94+
if (
95+
start_mileage is not None
96+
and end_mileage is not None
97+
and distance is not None
98+
and abs((end_mileage - start_mileage) - distance) > _LOCAL_DISTANCE_TOLERANCE_KM
99+
):
100+
flags.append("local_odometer_distance_mismatch")
101+
102+
duration = _number(row.get("duration_seconds"))
103+
if duration is None:
104+
start_time, end_time = _time(row.get("start_time")), _time(row.get("end_time"))
105+
if start_time is not None and end_time is not None:
106+
try:
107+
duration = (end_time - start_time).total_seconds()
108+
except TypeError:
109+
duration = None
110+
if duration is not None:
111+
if duration <= 0 or duration > _MAX_TRIP_DURATION_SECONDS:
112+
flags.append("local_duration_outlier")
113+
elif distance is not None and distance > 0:
114+
speed = distance / (duration / 3600)
115+
if speed > _MAX_LOCAL_BACKFILL_SPEED_KMH:
116+
flags.append("local_speed_outlier")
117+
118+
return not flags, flags
119+
120+
121+
def _validated_local_rows(
122+
local_trips: list[dict[str, Any]] | None,
123+
) -> list[dict[str, Any]]:
124+
"""Return only physically defensible local rows and annotate all candidates."""
125+
rows = [row for row in (local_trips or []) if isinstance(row, dict)]
126+
validated: list[dict[str, Any]] = []
127+
for row in rows:
128+
usable, flags = _local_trip_backfill_quality(row)
129+
row["backfill_eligible"] = usable
130+
if flags:
131+
existing = list(row.get("quality_flags") or [])
132+
row["quality_flags"] = list(dict.fromkeys([*existing, *flags]))
133+
if usable:
134+
validated.append(row)
135+
return validated
136+
137+
69138
def _plausible_anchor_end(trip: dict[str, Any]) -> float | None:
70139
"""Return an odometer anchor only from an already plausible canonical row."""
71140
if trip.get("valid_for_statistics") is False:
@@ -165,9 +234,16 @@ def repair_trip_odometer_continuity(
165234
of the corroborating sources produce a positive, speed-plausible distance,
166235
the row is left untouched and remains invalid. Raw server payloads are never
167236
mutated.
237+
238+
The supplied ``local_trips`` list is a caller-owned working copy in
239+
``server_history._rebuild_canonical``. It is intentionally narrowed in
240+
place to validated evidence so the immediately following energy-backfill
241+
step cannot consume a stale/physically impossible composite row.
168242
"""
169243
ordered = sorted(trips, key=_trip_sort_key)
170-
local_rows = [row for row in (local_trips or []) if isinstance(row, dict)]
244+
local_rows = _validated_local_rows(local_trips)
245+
if local_trips is not None:
246+
local_trips[:] = local_rows
171247
previous_end: float | None = None
172248

173249
for index, trip in enumerate(ordered):

0 commit comments

Comments
 (0)