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.
- aaos_logging_drive/__init__.py +20 -0
- aaos_logging_drive/api.py +134 -0
- aaos_logging_drive/battery_capacity.py +27 -0
- aaos_logging_drive/client.py +116 -0
- aaos_logging_drive/const.py +64 -0
- aaos_logging_drive/energy.py +143 -0
- aaos_logging_drive/history.py +458 -0
- aaos_logging_drive/measurements.py +98 -0
- aaos_logging_drive/model.py +297 -0
- aaos_logging_drive/py.typed +0 -0
- aaos_logging_drive/repository.py +227 -0
- aaos_logging_drive-0.1.0.dist-info/METADATA +145 -0
- aaos_logging_drive-0.1.0.dist-info/RECORD +16 -0
- aaos_logging_drive-0.1.0.dist-info/WHEEL +5 -0
- aaos_logging_drive-0.1.0.dist-info/licenses/LICENSE +21 -0
- aaos_logging_drive-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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."
|