|
15 | 15 | _ODOMETER_TOLERANCE_KM = 0.2 |
16 | 16 | _LOCAL_START_TOLERANCE_KM = 1.0 |
17 | 17 | _LOCAL_TIME_TOLERANCE_SECONDS = 20 * 60 |
| 18 | +_LOCAL_DISTANCE_TOLERANCE_KM = 2.0 |
18 | 19 | _MAX_TRIP_DISTANCE_KM = 1000.0 |
19 | 20 | _MAX_TRIP_DURATION_SECONDS = 24 * 60 * 60 |
20 | 21 | _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 |
21 | 26 |
|
22 | 27 | _REPAIRABLE_SOURCE_FLAGS = { |
23 | 28 | "missing_or_non_positive_distance", |
@@ -66,6 +71,70 @@ def _trip_sort_key(trip: dict[str, Any]) -> tuple[str, str]: |
66 | 71 | ) |
67 | 72 |
|
68 | 73 |
|
| 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 | + |
69 | 138 | def _plausible_anchor_end(trip: dict[str, Any]) -> float | None: |
70 | 139 | """Return an odometer anchor only from an already plausible canonical row.""" |
71 | 140 | if trip.get("valid_for_statistics") is False: |
@@ -165,9 +234,16 @@ def repair_trip_odometer_continuity( |
165 | 234 | of the corroborating sources produce a positive, speed-plausible distance, |
166 | 235 | the row is left untouched and remains invalid. Raw server payloads are never |
167 | 236 | 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. |
168 | 242 | """ |
169 | 243 | 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 |
171 | 247 | previous_end: float | None = None |
172 | 248 |
|
173 | 249 | for index, trip in enumerate(ordered): |
|
0 commit comments