aaos-logging-drive 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ """Read-only AAOS Logging Drive client and recorded vehicle data processing."""
2
+
3
+ from .api import DriveAuthError, DriveError, DriveNotFoundError, ReadOnlyDrive, folder_id
4
+ from .battery_capacity import capacity_telemetry
5
+ from .client import AAOSDriveClient
6
+ from .const import READ_SCOPE, readonly_grant
7
+ from .energy import EnergyReview, effective_telemetry, review_energy
8
+ from .history import HistoryStore, sync_history
9
+ from .model import Artifact, ExportNotReady, InvalidExport, Manifest, Snapshot, archive_trips, trip_summary
10
+ from .repository import DriveRepository
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "AAOSDriveClient", "Artifact", "DriveAuthError", "DriveError", "DriveNotFoundError",
16
+ "DriveRepository", "EnergyReview", "ExportNotReady", "HistoryStore", "InvalidExport",
17
+ "Manifest", "READ_SCOPE", "ReadOnlyDrive", "Snapshot", "archive_trips",
18
+ "capacity_telemetry", "effective_telemetry", "folder_id", "readonly_grant",
19
+ "review_energy", "sync_history", "trip_summary",
20
+ ]
@@ -0,0 +1,134 @@
1
+ """Google Drive API reads only. OAuth token refresh belongs to the caller."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ import re
8
+ from typing import Awaitable, Callable
9
+
10
+ import aiohttp
11
+
12
+ from .const import API_ROOT, FOLDER_MIME, MAX_FILE_BYTES
13
+ from .model import InvalidExport
14
+
15
+
16
+ class DriveError(Exception):
17
+ """A transient Drive read failed."""
18
+
19
+
20
+ class DriveAuthError(DriveError):
21
+ """The Drive grant needs renewed consent."""
22
+
23
+
24
+ class DriveNotFoundError(DriveError):
25
+ """A file vanished or is not yet visible during a generation replacement."""
26
+
27
+
28
+ class RetryableDriveError(DriveError):
29
+ """A rate limit or temporary Google service error."""
30
+
31
+ def __init__(self, message, retry_after=0):
32
+ super().__init__(message)
33
+ self.retry_after = retry_after
34
+
35
+
36
+ def folder_id(value: str) -> str:
37
+ value = value.strip()
38
+ match = re.fullmatch(r"https://drive\.google\.com/drive/(?:u/\d+/)?folders/([\w-]+)(?:\?.*)?", value)
39
+ result = match[1] if match else value
40
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", result):
41
+ raise InvalidExport("Enter a Google Drive folder ID or folder URL")
42
+ return result
43
+
44
+
45
+ class ReadOnlyDrive:
46
+ def __init__(self, session: aiohttp.ClientSession, token: Callable[[], Awaitable[str]]):
47
+ self.session = session
48
+ self.token = token
49
+
50
+ async def _read(self, path: str, params: dict, limit: int, media: bool = False):
51
+ for attempt in range(3):
52
+ try:
53
+ return await self._read_once(path, params, limit, media)
54
+ except (RetryableDriveError, aiohttp.ClientError, TimeoutError) as err:
55
+ if attempt == 2:
56
+ raise DriveError("Google Drive is temporarily unavailable after three read attempts") from err
57
+ delay = max(2 ** attempt + random.random(), getattr(err, "retry_after", 0))
58
+ await asyncio.sleep(min(delay, 60))
59
+
60
+ async def _read_once(self, path: str, params: dict, limit: int, media: bool = False):
61
+ access_token = await self.token()
62
+ async with self.session.get(
63
+ f"{API_ROOT}/{path}", params=params,
64
+ headers={"Authorization": f"Bearer {access_token}"},
65
+ timeout=aiohttp.ClientTimeout(total=180), allow_redirects=False,
66
+ ) as response:
67
+ if response.status == 401:
68
+ raise DriveAuthError("Google Drive authorization expired")
69
+ if response.status == 403:
70
+ # Quota exhaustion is transient; insufficient grants require reauth.
71
+ try:
72
+ error = await response.json()
73
+ reasons = {e.get("reason") for e in error.get("error", {}).get("errors", [])}
74
+ except (ValueError, aiohttp.ClientError):
75
+ reasons = set()
76
+ if reasons & {"insufficientPermissions", "authError"}:
77
+ raise DriveAuthError("Google Drive read-only permission is required")
78
+ if reasons & {"rateLimitExceeded", "userRateLimitExceeded"}:
79
+ raise RetryableDriveError("Google Drive read rate limit reached")
80
+ raise DriveError("Google Drive denied the read (permission, API configuration or quota)")
81
+ if response.status in {429, 500, 502, 503, 504}:
82
+ try:
83
+ retry_after = max(0, min(float(getattr(response, "headers", {}).get("Retry-After", "0")), 60))
84
+ except ValueError:
85
+ retry_after = 0
86
+ raise RetryableDriveError(f"Temporary Google Drive HTTP {response.status}", retry_after)
87
+ if response.status == 404:
88
+ raise DriveNotFoundError("A Google Drive file is not visible or accessible")
89
+ if response.status >= 300:
90
+ raise DriveError(f"Google Drive read failed with HTTP {response.status}")
91
+ data = bytearray()
92
+ async for chunk in response.content.iter_chunked(64 * 1024):
93
+ data.extend(chunk)
94
+ if len(data) > limit:
95
+ raise InvalidExport("Drive response exceeds the import limit")
96
+ if media:
97
+ return bytes(data)
98
+ from .model import json_object
99
+ return json_object(bytes(data))
100
+
101
+ async def metadata(self, file_id: str) -> dict:
102
+ return await self._read(f"files/{folder_id(file_id)}", {
103
+ "fields": "id,name,mimeType,size,sha256Checksum,trashed", "supportsAllDrives": "true",
104
+ }, 1024 * 1024)
105
+
106
+ async def children(self, parent: str) -> list[dict]:
107
+ parent = folder_id(parent)
108
+ result, page_token = [], None
109
+ while True:
110
+ params = {
111
+ "q": f"'{parent}' in parents and trashed = false", "spaces": "drive",
112
+ "fields": "nextPageToken,incompleteSearch,files(id,name,mimeType,size,sha256Checksum)",
113
+ "pageSize": "1000", "supportsAllDrives": "true", "includeItemsFromAllDrives": "true",
114
+ }
115
+ if page_token:
116
+ params["pageToken"] = page_token
117
+ page = await self._read("files", params, 8 * 1024 * 1024)
118
+ if page.get("incompleteSearch"):
119
+ raise DriveError("Drive returned an incomplete folder listing; retry later")
120
+ result.extend(page.get("files", []))
121
+ page_token = page.get("nextPageToken")
122
+ if not page_token:
123
+ return result
124
+
125
+ async def child_folder(self, parent: str, name: str) -> str:
126
+ matches = [f for f in await self.children(parent) if f.get("name") == name and f.get("mimeType") == FOLDER_MIME]
127
+ if len(matches) != 1:
128
+ raise InvalidExport(f"Expected one {name} folder; sync AAOS Logging V3 first")
129
+ return matches[0]["id"]
130
+
131
+ async def download(self, file_id: str, limit: int = MAX_FILE_BYTES) -> bytes:
132
+ return await self._read(f"files/{folder_id(file_id)}", {
133
+ "alt": "media", "supportsAllDrives": "true",
134
+ }, limit, media=True)
@@ -0,0 +1,27 @@
1
+ """Paired, timestamped usable-capacity estimate; never a certified SoH sensor."""
2
+
3
+ from math import isfinite
4
+
5
+ FIELDS = frozenset({"batteryCurrentCapacityWh", "batteryNominalCapacityWh", "batteryCapacityRetentionPercent"})
6
+ NOTICE = ("Estimated battery capacity retention, not verified battery state of health. "
7
+ "Current usable capacity / nominal usable capacity when new × 100. "
8
+ "Temperature and battery-management estimates affect this value; it does not measure permanent degradation. "
9
+ "Values above 100% are not capped. Missing capacities are not inferred from charge level, range or remaining energy.")
10
+
11
+
12
+ def positive(value):
13
+ try:
14
+ return value if type(value) in (int, float) and isfinite(value) and value > 0 else None
15
+ except OverflowError:
16
+ return None
17
+
18
+
19
+ def capacity_telemetry(point):
20
+ """Return a derived view, without modifying the original exported trip."""
21
+ result = dict(point)
22
+ current = positive(point.get("batteryCurrentCapacityWh"))
23
+ nominal = positive(point.get("batteryNominalCapacityWh"))
24
+ ratio = current / nominal * 100 if current is not None and nominal is not None else None
25
+ result.update(batteryCurrentCapacityWh=current, batteryNominalCapacityWh=nominal,
26
+ batteryCapacityRetentionPercent=positive(ratio))
27
+ return result
@@ -0,0 +1,116 @@
1
+ """Framework-independent synchronization and local-history API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from copy import deepcopy
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import aiohttp
12
+
13
+ from .api import DriveNotFoundError, ReadOnlyDrive, folder_id
14
+ from .history import HistoryStore, sync_history
15
+ from .model import ExportNotReady, Snapshot
16
+ from .repository import DriveRepository
17
+
18
+
19
+ class AAOSDriveClient:
20
+ """Coordinate complete imports, without owning credentials or HTTP sessions.
21
+
22
+ The low-level constructor accepts a repository and an initialized optional
23
+ HistoryStore. Use from_google_drive for automatic history initialization.
24
+ Persist accepted_head in the host application to retain rollback protection
25
+ across restarts; restore it through the factory's accepted_head argument.
26
+ """
27
+
28
+ def __init__(self, repository: DriveRepository, history: HistoryStore | None = None,
29
+ *, executor=asyncio.to_thread):
30
+ self.repository = repository
31
+ self.history = history
32
+ self.executor = executor
33
+ self._lock = asyncio.Lock()
34
+ self._initialize_history = False
35
+
36
+ @classmethod
37
+ def from_google_drive(cls, session: aiohttp.ClientSession, token_provider, vehicle_folder: str,
38
+ *, history_path: str | Path | None = None,
39
+ expected_identity: dict | None = None, accepted_head: dict | None = None,
40
+ executor=asyncio.to_thread) -> AAOSDriveClient:
41
+ """Use a caller-owned aiohttp session and async access-token provider.
42
+
43
+ Google OAuth consent and token refresh remain the caller's responsibility.
44
+ No credential is written to the local history database.
45
+ """
46
+ repository = DriveRepository(ReadOnlyDrive(session, token_provider), folder_id(vehicle_folder),
47
+ executor, expected_identity)
48
+ repository.accepted_head = dict(accepted_head) if accepted_head else None
49
+ history = HistoryStore(history_path) if history_path is not None else None
50
+ client = cls(repository, history, executor=executor)
51
+ client._initialize_history = history is not None
52
+ return client
53
+
54
+ @property
55
+ def snapshot(self) -> Snapshot | None:
56
+ return self.repository.snapshot
57
+
58
+ @property
59
+ def accepted_head(self) -> dict | None:
60
+ head = self.repository.accepted_head
61
+ return deepcopy(head) if head else None
62
+
63
+ async def refresh(self) -> Snapshot:
64
+ """Read the latest complete generation and retain old data on failure.
65
+
66
+ Publication races are retried three times. A generation is accepted only
67
+ after every required archive is validated and local history is complete.
68
+ """
69
+ async with self._lock:
70
+ if self._initialize_history:
71
+ await self.executor(self.history.initialize)
72
+ self._initialize_history = False
73
+ for attempt in range(3):
74
+ try:
75
+ snapshot = await self.repository.refresh(accept=False)
76
+ if self.history is not None:
77
+ await sync_history(self.history, self.repository, snapshot, self.executor)
78
+ break
79
+ except (ExportNotReady, DriveNotFoundError):
80
+ if attempt == 2:
81
+ raise
82
+ await asyncio.sleep(2 ** attempt)
83
+ self.repository.accept_snapshot(snapshot)
84
+ snapshot.checked_at = datetime.now(timezone.utc)
85
+ if snapshot.previous_identity is not None:
86
+ self.repository.expected_identity = {
87
+ key: snapshot.manifest.raw[key] for key in ("datasetId", "vehicleId")
88
+ }
89
+ return snapshot
90
+
91
+ async def _query(self, method: str, *args) -> Any:
92
+ async with self._lock:
93
+ if self.history is None:
94
+ raise ValueError("Configure history_path to query complete recorded history")
95
+ if self._initialize_history:
96
+ await self.executor(self.history.initialize)
97
+ self._initialize_history = False
98
+ if not (await self.executor(self.history.state))["generation_id"]:
99
+ raise ValueError("Import a complete generation before querying history")
100
+ return await self.executor(getattr(self.history, method), *args)
101
+
102
+ async def history_fields(self) -> dict:
103
+ return await self._query("overview")
104
+
105
+ async def list_trips(self, period: str = "") -> list[dict]:
106
+ return await self._query("summaries", period)
107
+
108
+ async def get_trip(self, trip_id: str = "") -> dict:
109
+ """Return the exact original trip, including its raw sample arrays."""
110
+ return await self._query("trip", trip_id)
111
+
112
+ async def get_history(self, field: str, *, start: int | None = None, end: int | None = None,
113
+ limit: int = 2000, cursor: dict | None = None,
114
+ trip_id: str | None = None) -> dict:
115
+ """Return timestamped readings and a generation-bound next_cursor."""
116
+ return await self._query("points", field, start, end, limit, cursor, trip_id)
@@ -0,0 +1,64 @@
1
+ """AAOS Logging import constants."""
2
+
3
+ READ_SCOPE = "https://www.googleapis.com/auth/drive.readonly"
4
+ API_ROOT = "https://www.googleapis.com/drive/v3"
5
+ FOLDER_MIME = "application/vnd.google-apps.folder"
6
+ MAX_FILE_BYTES = 128 * 1024 * 1024
7
+ MAX_ZIP_BYTES = 512 * 1024 * 1024
8
+ HISTORY_PROCESSING_VERSION = 4
9
+
10
+
11
+ def readonly_grant(token: dict) -> bool:
12
+ scopes = set(token.get("scope", "").split())
13
+ return READ_SCOPE in scopes and not any(
14
+ s.startswith("https://www.googleapis.com/auth/drive") and s != READ_SCOPE for s in scopes
15
+ )
16
+
17
+ # Exact keys from AAOS Logging's TelemetryJsonContract (trip schema 17).
18
+ TELEMETRY_FIELDS = (
19
+ "recordedAt", "vehicleType", "vehicleTypeSource", "fuelLevelLiters",
20
+ "fuelCapacityLiters", "fuelLevelLow", "fuelDoorOpen", "fuelConsumptionLitersPer100Km",
21
+ "speedKph", "acceleratorPercent", "brakePercent", "lateralG",
22
+ "longitudinalG", "powerKw", "powerSource", "powerSignalDirection", "batteryEnergyWh", "turnSignalState",
23
+ "steeringAngleDeg", "batteryPercent", "batteryTemperatureC",
24
+ "batteryCurrentCapacityWh", "batteryNominalCapacityWh", "batteryCapacityRetentionPercent",
25
+ "vehicleOutsideTemperatureC", "chargePortConnected", "chargePortOpen", "chargeState",
26
+ "tirePressureKpaLeftFront", "tirePressureKpaRightFront", "tirePressureKpaLeftRear",
27
+ "tirePressureKpaRightRear", "brakeFluidLevelLow", "brakePadWearPercentLeftFront",
28
+ "brakePadWearPercentRightFront", "brakePadWearPercentLeftRear",
29
+ "brakePadWearPercentRightRear", "headlightsState", "highBeamLightsState",
30
+ "fogLightsState", "frontFogLightsState", "rearFogLightsState", "hazardLightsState",
31
+ "windshieldWipersState", "absActive", "tractionControlActive",
32
+ "electronicStabilityControlState", "currentGear", "parkingBrakeOn",
33
+ "parkingBrakeAutoApply", "regenerativeBrakingLevel", "evStoppingMode", "nightMode",
34
+ "wheelTickResetCount", "wheelTickLeftFront", "wheelTickRightFront", "wheelTickRightRear",
35
+ "wheelTickLeftRear", "seatOccupancyFrontLeft", "seatOccupancyFrontRight",
36
+ "seatOccupancyRearLeft", "seatOccupancyRearRight", "engineRpm",
37
+ "engineCoolantTemperatureC", "engineOilTemperatureC", "engineOilLevel",
38
+ "engineOilPressureKpa", "forwardCollisionWarningState", "automaticEmergencyBrakingState",
39
+ "laneDepartureWarningState", "laneKeepAssistState", "laneCenteringAssistState",
40
+ "blindSpotWarningLeftState", "blindSpotWarningRightState", "cruiseControlState",
41
+ "cruiseControlTargetSpeedKph", "adaptiveCruiseLeadDistanceM", "rangeKm", "gearSelection",
42
+ )
43
+ BINARY_FIELDS = frozenset({
44
+ "chargePortConnected", "chargePortOpen", "brakeFluidLevelLow", "absActive",
45
+ "tractionControlActive", "parkingBrakeOn", "parkingBrakeAutoApply", "nightMode",
46
+ "fuelLevelLow", "fuelDoorOpen",
47
+ })
48
+ FUEL_USAGE_MEASUREMENTS = (
49
+ "movingFuelLiters", "movingConsumptionLitersPer100Km", "coveredDistanceKm",
50
+ "observedDistanceKm", "tripDistanceKm", "engineRunningSeconds", "engineObservedSeconds",
51
+ "engineOffMovingSeconds", "coveragePercent",
52
+ )
53
+ FUEL_USAGE_FIELDS = (*FUEL_USAGE_MEASUREMENTS, "source", "includesStationaryFuel", "limitations")
54
+ TRIP_FIELDS = (
55
+ "tripId", "localId", "startedAt", "endedAt", "storageUnitSystem", "distanceKm",
56
+ "energyKwh", "energySource", "powerSignalDirection", "averageSpeedKph", "maxSpeedKph", "maxAccelerationG",
57
+ "maxBrakingG", "maxLateralG", "startLat", "startLon", "endLat", "endLon", "routeKey",
58
+ "durationSeconds", "efficiencyKwhPer100Km", "telemetryCount", "routePointCount",
59
+ "weatherSampleCount",
60
+ "energyQuality", "energyReviewVersion", "energyDescription", "reportedEnergyKwh", "reportedEnergySource",
61
+ "vehicleType", "vehicleTypeSource", "powertrainSchemaVersion",
62
+ *(f"fuelUsage.{key}" for key in FUEL_USAGE_FIELDS),
63
+ )
64
+ WEATHER_FIELDS = ("temperatureC", "windKph", "precipitationMm", "capturedAt", "latitude", "longitude")
@@ -0,0 +1,143 @@
1
+ """Conservative battery-energy recovery, shared in behaviour with both Android apps.
2
+
3
+ Never mutates an export. Trip energy uses qualified endpoints, not integration
4
+ of the smoothed power estimate. Fuel use is never inferred from battery depletion.
5
+ """
6
+
7
+ from collections import deque
8
+ from dataclasses import dataclass
9
+ import math
10
+
11
+ DERIVED = {"derived_battery_energy", "mixed_vehicle_sensor_and_derived"}
12
+ KNOWN = DERIVED | {"vehicle_sensor"}
13
+ REVIEW_VERSION = 1
14
+ QUALITY_FIELDS = {"reportedPowerKw", "reportedPowerSource", "powerQuality"}
15
+
16
+
17
+ def finite(value):
18
+ return type(value) in (int, float) and math.isfinite(value)
19
+
20
+
21
+ def charging(point):
22
+ return point.get("chargePortConnected") is True or point.get("chargeState") == 1
23
+
24
+
25
+ class BatteryEvidence:
26
+ def __init__(self):
27
+ self.first_at = self.last_at = None
28
+ self.first_wh = self.last_wh = None
29
+ self.previous_at = self.previous_wh = self.observation_at = None
30
+ self.invalid = False
31
+ self.changes = self.moving_ms = self.power_count = self.nonzero_count = 0
32
+
33
+ def add(self, point):
34
+ at, power, wh, speed = (point.get(k) for k in ("recordedAt", "powerKw", "batteryEnergyWh", "speedKph"))
35
+ if finite(power):
36
+ self.power_count += 1
37
+ self.nonzero_count += abs(power) > .01
38
+ if self.observation_at is not None and finite(speed) and abs(speed) >= 3 and 1 <= at - self.observation_at <= 15000:
39
+ self.moving_ms += at - self.observation_at
40
+ self.observation_at = at
41
+ if charging(point) or not finite(wh) or wh < 0:
42
+ self.invalid = True
43
+ return
44
+ if self.previous_at is None:
45
+ self.first_at, self.first_wh = at, wh
46
+ else:
47
+ elapsed = at - self.previous_at
48
+ delta = abs(wh - self.previous_wh)
49
+ if not 1 <= elapsed <= 15000 or delta > max(50, max(0, elapsed) * 500 / 3600):
50
+ self.invalid = True
51
+ self.changes += delta >= 1
52
+ self.previous_at, self.previous_wh = at, wh
53
+ self.last_at, self.last_wh = at, wh
54
+
55
+ def battery_kwh(self, start, end):
56
+ if (self.invalid or self.first_wh is None or not finite(end) or end <= start or
57
+ self.last_at - self.first_at < 10000 or abs(self.first_at - start) > 10000 or abs(end - self.last_at) > 10000):
58
+ return None
59
+ energy = (self.first_wh - self.last_wh) / 1000
60
+ return energy if abs(energy) * 3600000 / (end - start) <= 250 else None
61
+
62
+ def suspect_zero(self, start, end):
63
+ if not finite(end) or end - start < 30000 or self.nonzero_count or self.power_count < 4 or self.moving_ms < 10000:
64
+ return False
65
+ contradiction = (self.changes >= 3 and self.first_wh is not None and self.last_wh is not None and
66
+ abs(self.first_wh - self.last_wh) >= 20)
67
+ return contradiction or self.battery_kwh(start, end) is None
68
+
69
+
70
+ class BatteryPowerWindow:
71
+ def __init__(self):
72
+ self.samples = deque()
73
+
74
+ def add(self, point):
75
+ at, wh = point.get("recordedAt"), point.get("batteryEnergyWh")
76
+ if charging(point) or not finite(wh) or wh < 0:
77
+ self.samples.clear()
78
+ return None
79
+ if self.samples:
80
+ last_at, last_wh = self.samples[-1]
81
+ elapsed = at - last_at
82
+ if not 1 <= elapsed <= 15000 or abs(wh - last_wh) > max(50, max(0, elapsed) * 500 / 3600):
83
+ self.samples.clear()
84
+ self.samples.append((at, wh))
85
+ while len(self.samples) > 1 and at - self.samples[0][0] > 60000:
86
+ self.samples.popleft()
87
+ first_at, first_wh = self.samples[0]
88
+ if at - first_at < 10000:
89
+ return None
90
+ power = (first_wh - wh) * 3600 / (at - first_at)
91
+ return power if finite(power) and abs(power) <= 250 else None
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class EnergyReview:
96
+ energy: float | None
97
+ source: str | None
98
+ quality: str
99
+ repair_power: bool = False
100
+
101
+
102
+ def review_energy(trip):
103
+ energy, source = trip.get("energyKwh"), trip.get("energySource")
104
+ if trip.get("vehicleType") == "FUEL":
105
+ usable = finite(energy) and source in KNOWN
106
+ return EnergyReview(energy if usable else None, source, "estimated" if usable and source in DERIVED else "measured" if usable else "unavailable")
107
+ # Normal measured history needs no battery scan and has no artificial power cap.
108
+ if finite(energy) and energy != 0 and source != "unavailable_energy":
109
+ return EnergyReview(energy, source, "estimated" if source in DERIVED else "measured")
110
+ evidence = BatteryEvidence()
111
+ for point in trip.get("telemetry", []):
112
+ evidence.add(point)
113
+ start, end = trip["startedAt"], trip.get("endedAt")
114
+ suspect = energy == 0 and evidence.suspect_zero(start, end)
115
+ missing = (source == "unavailable_energy" or not finite(energy) or
116
+ energy == 0 and evidence.power_count == 0 and source not in KNOWN)
117
+ if suspect or missing:
118
+ recovered = evidence.battery_kwh(start, end)
119
+ if recovered is not None:
120
+ return EnergyReview(recovered, "derived_battery_energy", "recovered", True)
121
+ return EnergyReview(None, "unavailable_energy", "unavailable", True)
122
+ return EnergyReview(energy, source, "estimated" if source in DERIVED else "measured")
123
+
124
+
125
+ def effective_telemetry(trip, review):
126
+ """One bounded rolling window per affected trip; all other readings stay exact."""
127
+ window = BatteryPowerWindow()
128
+ for point in trip.get("telemetry", []):
129
+ if not review.repair_power:
130
+ yield point
131
+ continue
132
+ power = window.add(point) if review.quality == "recovered" else None
133
+ yield {**point, "reportedPowerKw": point.get("powerKw"), "reportedPowerSource": point.get("powerSource"),
134
+ "powerKw": power, "powerSource": review.source,
135
+ "powerQuality": "estimated" if power is not None else "unavailable"}
136
+
137
+
138
+ def energy_description(quality):
139
+ if quality in {"recovered", "estimated"}:
140
+ return "Estimated net battery energy, including regeneration and auxiliaries. Smoothed power cannot show precise peaks. Hybrid fuel use is separate."
141
+ if quality == "unavailable":
142
+ return "Battery consumption unavailable: insufficient reliable energy readings; excluded from consumption averages."
143
+ return "Vehicle-measured battery energy."