mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
449 lines
20 KiB
Python
449 lines
20 KiB
Python
"""Measured statistics from a bounded, immutable local route recording."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from contextlib import contextmanager
|
|
from datetime import UTC, datetime
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
import time
|
|
|
|
from openpilot.starpilot.flm.local_logs import DIR_FLAGS, FILE_FLAGS, MAX_COMPRESSED_BYTES, LocalLogUnavailable
|
|
from openpilot.starpilot.flm.log_decode import LogDecodeError, decode_segment
|
|
from openpilot.starpilot.galaxy.lateral_stats import ControlSamples, aol_statistics
|
|
from openpilot.starpilot.galaxy.drive_history import MAX_ROOT_ENTRIES, MAX_SEGMENT_ENTRIES, MAX_STATS_SEGMENTS_PER_ROUTE, SEGMENT_NAME
|
|
from openpilot.starpilot.models.catalog import model_entries
|
|
from openpilot.starpilot.models.receipt import logged_model_load
|
|
from openpilot.starpilot.models.status import ModelVariant
|
|
|
|
|
|
MAX_ANALYSIS_SECONDS = 30.0
|
|
MAX_PAIR_SECONDS = 1.0
|
|
MAX_SPEED_MPS = 70.0
|
|
MAX_ROUTE_COMPRESSED_BYTES = 256 * 1024 * 1024
|
|
ANALYSIS_VERSION = 4
|
|
MAX_MODEL_METADATA_SECONDS = 4.0
|
|
DISTRACTED_EVENT = 'driverDistracted2'
|
|
UNRESPONSIVE_EVENT = 'driverUnresponsive3'
|
|
LOGS = {'qlog.zst': 'zst', 'qlog.bz2': 'bz2', 'rlog.zst': 'zst', 'rlog.bz2': 'bz2'}
|
|
|
|
|
|
class _Incomplete(Exception):
|
|
pass
|
|
|
|
|
|
def _revision(info: os.stat_result) -> tuple[int, ...]:
|
|
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
|
|
|
|
|
|
@contextmanager
|
|
def _closed_source(root: Path, segment_name: str, expected: dict, allowed: Callable[[], bool], *, inspect_only: bool = False,
|
|
prefer_rlog: bool = False):
|
|
fds: list[int] = []
|
|
try:
|
|
root_fd = os.open(root, DIR_FLAGS)
|
|
fds.append(root_fd)
|
|
segment_fd = os.open(segment_name, DIR_FLAGS, dir_fd=root_fd)
|
|
fds.append(segment_fd)
|
|
root_identity = os.fstat(root_fd).st_dev, os.fstat(root_fd).st_ino
|
|
segment_identity = os.fstat(segment_fd).st_dev, os.fstat(segment_fd).st_ino
|
|
found: list[str] = []
|
|
with os.scandir(segment_fd) as entries:
|
|
for count, entry in enumerate(entries, 1):
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
if count > MAX_SEGMENT_ENTRIES or entry.name.endswith('.lock'):
|
|
raise _Incomplete('Segment is open or unavailable')
|
|
if entry.name in LOGS:
|
|
found.append(entry.name)
|
|
preferred = [name for name in found if name.startswith('rlog.' if prefer_rlog else 'qlog.')]
|
|
if any(name.startswith('qlog.') for name in found) != (expected.get('qlog') is True) or \
|
|
any(name.startswith('rlog.') for name in found) != (expected.get('rlog') is True):
|
|
raise _Incomplete('Route source changed')
|
|
if len(preferred) > 1 or (not preferred and len(found) != 1):
|
|
raise _Incomplete('One closed qlog or full rlog is required')
|
|
filename = preferred[0] if preferred else found[0]
|
|
fd = os.open(filename, FILE_FLAGS, dir_fd=segment_fd)
|
|
fds.append(fd)
|
|
initial = os.fstat(fd)
|
|
if not stat.S_ISREG(initial.st_mode) or not 0 < initial.st_size <= MAX_COMPRESSED_BYTES:
|
|
raise _Incomplete('Route log is empty, oversized or unavailable')
|
|
revision = _revision(initial)
|
|
evidence = (root_identity, segment_identity, filename, revision, tuple(sorted(found)))
|
|
|
|
def current() -> bool:
|
|
try:
|
|
root_now = os.stat(root, follow_symlinks=False)
|
|
segment_now = os.stat(segment_name, dir_fd=root_fd, follow_symlinks=False)
|
|
source_now = os.stat(filename, dir_fd=segment_fd, follow_symlinks=False)
|
|
if not stat.S_ISDIR(root_now.st_mode) or not stat.S_ISDIR(segment_now.st_mode) or \
|
|
not stat.S_ISREG(source_now.st_mode) or \
|
|
(root_now.st_dev, root_now.st_ino) != root_identity or \
|
|
(segment_now.st_dev, segment_now.st_ino) != segment_identity or \
|
|
_revision(source_now) != revision or _revision(os.fstat(fd)) != revision:
|
|
return False
|
|
with os.scandir(segment_fd) as entries:
|
|
names = []
|
|
for count, entry in enumerate(entries, 1):
|
|
if count > MAX_SEGMENT_ENTRIES or entry.name.endswith('.lock'):
|
|
return False
|
|
if entry.name in LOGS:
|
|
names.append(entry.name)
|
|
return sorted(names) == sorted(found)
|
|
except OSError:
|
|
return False
|
|
|
|
if not current():
|
|
raise _Incomplete('Route source changed')
|
|
if inspect_only:
|
|
yield None, LOGS[filename], current, evidence
|
|
return
|
|
chunks: list[bytes] = []
|
|
remaining = initial.st_size
|
|
while remaining:
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
chunk = os.read(fd, min(1024 * 1024, remaining))
|
|
if not chunk:
|
|
raise _Incomplete('Route source changed')
|
|
chunks.append(chunk)
|
|
remaining -= len(chunk)
|
|
if os.read(fd, 1) or not current():
|
|
raise _Incomplete('Route source changed')
|
|
yield b''.join(chunks), LOGS[filename], current, evidence
|
|
except OSError as error:
|
|
raise _Incomplete('Route source is unavailable') from error
|
|
finally:
|
|
for fd in reversed(fds):
|
|
os.close(fd)
|
|
|
|
|
|
def _route_names(root: Path, route_id: str, allowed: Callable[[], bool]) -> set[str]:
|
|
names: set[str] = set()
|
|
try:
|
|
with os.scandir(root) as entries:
|
|
for count, entry in enumerate(entries, 1):
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
if count > MAX_ROOT_ENTRIES:
|
|
raise _Incomplete('Route inventory is incomplete')
|
|
matched = SEGMENT_NAME.fullmatch(entry.name)
|
|
if matched is not None and matched.group('route').replace('_', '|') == route_id:
|
|
names.add(entry.name)
|
|
except OSError as error:
|
|
raise _Incomplete('Route directory is unavailable') from error
|
|
return names
|
|
|
|
|
|
def _selected(route: dict) -> tuple[str, list[str]]:
|
|
if not isinstance(route, dict):
|
|
raise _Incomplete('Invalid route selection')
|
|
route_id = route.get('routeId')
|
|
segments = route.get('segments')
|
|
if type(route_id) is not str or type(segments) is not list or not 1 <= len(segments) <= MAX_STATS_SEGMENTS_PER_ROUTE or \
|
|
route.get('segmentCount') != len(segments):
|
|
raise _Incomplete('Invalid route selection')
|
|
names: list[str] = []
|
|
for index, segment in enumerate(segments):
|
|
if not isinstance(segment, dict) or segment.get('number') != index or \
|
|
not isinstance(segment.get('files'), dict) or \
|
|
not (segment['files'].get('qlog') is True or segment['files'].get('rlog') is True):
|
|
raise _Incomplete('Route has missing segments or logs')
|
|
name = segment.get('segmentName')
|
|
matched = SEGMENT_NAME.fullmatch(name) if type(name) is str and len(name) <= 180 else None
|
|
if matched is None or matched.group('route').replace('_', '|') != route_id or int(matched.group('number')) != index:
|
|
raise _Incomplete('Invalid route selection')
|
|
assert isinstance(name, str)
|
|
names.append(name)
|
|
if len(set(names)) != len(names):
|
|
raise _Incomplete('Duplicate route segment')
|
|
return route_id, names
|
|
|
|
|
|
def _route_wall_time(route_id: str) -> float | None:
|
|
token = route_id.rsplit('|', maxsplit=1)[-1]
|
|
try:
|
|
if len(token) != 20 or token[4] != '-' or token[10:12] != '--':
|
|
return None
|
|
value = datetime.strptime(token, '%Y-%m-%d--%H-%M-%S').replace(tzinfo=UTC).timestamp()
|
|
return value if 946684800 <= value <= datetime.now(UTC).timestamp() + 86400 else None
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _recorded_model(init_data) -> str | None:
|
|
try:
|
|
values = {str(entry.key): bytes(entry.value).decode('utf-8', 'replace').strip()
|
|
for entry in init_data.params.entries if str(entry.key) in ('DrivingModelName', 'DrivingModel', 'Model')}
|
|
except (AttributeError, TypeError, ValueError):
|
|
return None
|
|
for key in ('DrivingModelName', 'DrivingModel', 'Model'):
|
|
value = values.get(key, '')
|
|
if 0 < len(value) <= 100 and value.isprintable():
|
|
return value
|
|
return None
|
|
|
|
|
|
def _model_label(outputs, loads, selected):
|
|
"""Join actual output variants to recorded load boundaries; saved intent is not execution."""
|
|
if not outputs:
|
|
return f'Selected: {selected}' if selected else None
|
|
loads = sorted(set(loads), key=lambda item: item[0])
|
|
labels = []
|
|
index = -1
|
|
for stamp, big in sorted(set(outputs)):
|
|
while index + 1 < len(loads) and loads[index + 1][0] <= stamp:
|
|
index += 1
|
|
load = loads[index][1] if index >= 0 else None
|
|
variant = 'Chestnut big' if big else 'Small'
|
|
if load is not None and (load.variant is ModelVariant.CHESTNUT) == big:
|
|
entry = model_entries().get(load.model_id)
|
|
label = f'{entry.name if entry is not None else load.model_id} ({variant})'
|
|
else:
|
|
label = f'{variant} model (identity not recorded)'
|
|
if label not in labels:
|
|
labels.append(label)
|
|
combined = ' → '.join(labels)
|
|
return combined if len(combined) <= 120 else 'Multiple recorded models'
|
|
|
|
|
|
def _rlog_model_loads(root, route, names, allowed):
|
|
"""Optional exact identities. A missing/oversized log never prevents drive statistics."""
|
|
deadline = time.monotonic() + MAX_MODEL_METADATA_SECONDS
|
|
def permitted():
|
|
return time.monotonic() < deadline and allowed()
|
|
loads, evidence = [], []
|
|
total = 0
|
|
try:
|
|
for index, name in enumerate(names):
|
|
files = route['segments'][index]['files']
|
|
if not files.get('rlog'):
|
|
return [] # Missing intervals cannot prove which model ran for the whole route.
|
|
with _closed_source(root, name, files, permitted, prefer_rlog=True) as (compressed, codec, current, revision):
|
|
if not current():
|
|
return []
|
|
total += len(compressed)
|
|
if total > MAX_ROUTE_COMPRESSED_BYTES:
|
|
return []
|
|
for event in decode_segment(compressed, codec, cancelled=lambda: not permitted()):
|
|
if event.valid and event.which() == 'logMessage':
|
|
raw = str(event.logMessage)
|
|
if (load := logged_model_load(raw)) is not None and 0 < load.loaded_mono_ns <= event.logMonoTime:
|
|
loads.append((load.loaded_mono_ns, load))
|
|
elif len(raw) <= 8192:
|
|
try:
|
|
record = json.loads(raw)
|
|
if isinstance(record, dict) and record.get('msg') == 'modeld init' and record.get('module') == 'modeld':
|
|
loads.append((int(event.logMonoTime), None))
|
|
except (ValueError, TypeError, RecursionError):
|
|
pass
|
|
evidence.append(revision)
|
|
for index, name in enumerate(names):
|
|
with _closed_source(root, name, route['segments'][index]['files'], permitted,
|
|
inspect_only=True, prefer_rlog=True) as (_, _, current, revision):
|
|
if not current() or revision != evidence[index]:
|
|
return []
|
|
return loads
|
|
except (_Incomplete, LocalLogUnavailable, LogDecodeError, OSError, ValueError):
|
|
return []
|
|
|
|
|
|
def analyze_route(root: Path, route: dict, *, permitted: Callable[[], bool], prefer_rlog: bool = False) -> dict:
|
|
"""Return a complete route only when every selected source and sample span is sound."""
|
|
result = {'routeId': route.get('routeId') if isinstance(route, dict) else None,
|
|
'startTime': None, 'endTime': None, 'distanceMeters': None, 'durationSeconds': None,
|
|
'engagedSeconds': None, 'engagedPercent': None, 'aolSeconds': None, 'aolPercent': None, 'model': None,
|
|
'distractedMoments': None, 'unresponsiveMoments': None,
|
|
'complete': False, 'reason': None, 'segmentCount': 0, 'analysisVersion': ANALYSIS_VERSION}
|
|
segment_count = len(route.get('segments', [])) if isinstance(route, dict) else 0
|
|
deadline = time.monotonic() + min(120.0, max(60.0, segment_count * 14.0))
|
|
def allowed() -> bool:
|
|
if time.monotonic() > deadline:
|
|
return False
|
|
try:
|
|
return permitted()
|
|
except Exception:
|
|
return False
|
|
try:
|
|
if not isinstance(root, Path) or not root.is_absolute() or '..' in root.parts:
|
|
raise _Incomplete('Invalid recording root')
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
route_id, names = _selected(route)
|
|
result['segmentCount'] = len(names)
|
|
if _route_names(root, route_id, allowed) != set(names):
|
|
raise _Incomplete('Route segments changed or are incomplete')
|
|
|
|
first_car = last_car = first_state = last_state = None
|
|
previous_car = previous_state = None
|
|
control_samples = ControlSamples()
|
|
distance = engaged = 0.0
|
|
car_pairs = state_pairs = 0
|
|
coverage_gap = False
|
|
end_of_route = False
|
|
clocks_offset = None
|
|
model = None
|
|
model_outputs = []
|
|
model_loads = []
|
|
previous_events: set[str] = set()
|
|
distracted = unresponsive = 0
|
|
first_attention = last_attention = None
|
|
attention_gap = False
|
|
compressed_total = 0
|
|
evidence = []
|
|
for index, name in enumerate(names):
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
with _closed_source(root, name, route['segments'][index]['files'], allowed, prefer_rlog=prefer_rlog) as (compressed, codec, current, source_evidence):
|
|
if not current():
|
|
raise _Incomplete('Route source changed')
|
|
evidence.append(source_evidence)
|
|
compressed_total += len(compressed)
|
|
if compressed_total > MAX_ROUTE_COMPRESSED_BYTES:
|
|
raise _Incomplete('Route source changed or exceeds analysis limit')
|
|
events = decode_segment(compressed, codec, cancelled=lambda: not allowed(),
|
|
retain=frozenset(('clocks', 'initData', 'logMessage', 'modelV2', 'drivingModelData',
|
|
'carState', 'selfdriveState', 'carControl', 'onroadEvents', 'sentinel')))
|
|
segment_end = False
|
|
for event in events:
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
kind = event.which()
|
|
stamp = int(event.logMonoTime)
|
|
if kind == 'carControl':
|
|
control_samples.append(stamp, bool(event.valid), bool(event.carControl.latActive and not event.carControl.enabled))
|
|
if stamp <= 0 or not event.valid:
|
|
continue
|
|
if kind == 'clocks':
|
|
wall = int(event.clocks.wallTimeNanos)
|
|
if 946684800 * 1_000_000_000 <= wall <= (datetime.now(UTC).timestamp() + 86400) * 1_000_000_000:
|
|
offset = (wall - stamp) / 1e9
|
|
if clocks_offset is not None and abs(offset - clocks_offset) > 5:
|
|
raise _Incomplete('Conflicting logged wall clocks')
|
|
clocks_offset = offset if clocks_offset is None else clocks_offset
|
|
elif kind == 'initData' and model is None:
|
|
model = _recorded_model(event.initData)
|
|
elif kind == 'logMessage':
|
|
load = logged_model_load(str(event.logMessage))
|
|
if load is not None and 0 < load.loaded_mono_ns <= stamp:
|
|
model_loads.append((load.loaded_mono_ns, load))
|
|
elif len(str(event.logMessage)) <= 8192:
|
|
try:
|
|
marker = json.loads(str(event.logMessage)).get('msg', {})
|
|
if marker == 'modeld init':
|
|
model_loads.append((stamp, None))
|
|
if (isinstance(marker, dict) and marker.get('event') == 'modeld.identity-unavailable' and
|
|
type(marker.get('sinceMonoNs')) is int and 0 < marker['sinceMonoNs'] <= stamp):
|
|
model_loads.append((marker['sinceMonoNs'], None))
|
|
except (ValueError, TypeError, AttributeError):
|
|
pass
|
|
elif kind in ('modelV2', 'drivingModelData'):
|
|
model_outputs.append((stamp, bool(getattr(event, kind).big)))
|
|
elif kind == 'carState':
|
|
speed = float(event.carState.vEgo)
|
|
if not math.isfinite(speed) or not -.5 <= speed <= MAX_SPEED_MPS or (last_car is not None and stamp <= last_car):
|
|
coverage_gap = True
|
|
previous_car = None
|
|
continue
|
|
speed = max(0.0, speed)
|
|
if previous_car is not None:
|
|
delta = (stamp - previous_car[0]) / 1e9
|
|
if delta > MAX_PAIR_SECONDS:
|
|
coverage_gap = True
|
|
else:
|
|
distance += (previous_car[1] + speed) * .5 * delta
|
|
car_pairs += 1
|
|
first_car = stamp if first_car is None else first_car
|
|
last_car = stamp
|
|
previous_car = stamp, speed
|
|
elif kind == 'selfdriveState':
|
|
if last_state is not None and stamp <= last_state:
|
|
coverage_gap = True
|
|
previous_state = None
|
|
continue
|
|
if previous_state is not None:
|
|
delta = (stamp - previous_state[0]) / 1e9
|
|
if delta > MAX_PAIR_SECONDS:
|
|
coverage_gap = True
|
|
else:
|
|
engaged += delta if previous_state[1] else 0.0
|
|
state_pairs += 1
|
|
first_state = stamp if first_state is None else first_state
|
|
last_state = stamp
|
|
previous_state = stamp, bool(event.selfdriveState.enabled)
|
|
elif kind == 'onroadEvents':
|
|
if last_attention is not None and not 0 < (stamp - last_attention) / 1e9 <= 5:
|
|
attention_gap = True
|
|
first_attention = stamp if first_attention is None else first_attention
|
|
last_attention = stamp
|
|
active = {str(item.name).split('.')[-1] for item in event.onroadEvents}
|
|
distracted += DISTRACTED_EVENT in active and DISTRACTED_EVENT not in previous_events
|
|
unresponsive += UNRESPONSIVE_EVENT in active and UNRESPONSIVE_EVENT not in previous_events
|
|
previous_events = active
|
|
elif kind == 'sentinel':
|
|
sentinel_type = str(event.sentinel.type).split('.')[-1]
|
|
if sentinel_type == 'endOfRoute':
|
|
if index != len(names) - 1:
|
|
raise _Incomplete('Route ended before its last segment')
|
|
segment_end = True
|
|
if index == len(names) - 1:
|
|
end_of_route = segment_end
|
|
del events, compressed
|
|
if _route_names(root, route_id, allowed) != set(names):
|
|
raise _Incomplete('Route source changed')
|
|
for index, name in enumerate(names):
|
|
if not allowed():
|
|
raise _Incomplete('Analysis cancelled')
|
|
with _closed_source(root, name, route['segments'][index]['files'], allowed, inspect_only=True, prefer_rlog=prefer_rlog) as (_, _, current, observed):
|
|
if evidence[index] != observed or not current():
|
|
raise _Incomplete('Route source changed')
|
|
|
|
if first_car is None or last_car is None or first_state is None or last_state is None:
|
|
raise _Incomplete('Route has no observed duration')
|
|
first = min(first_car, first_state)
|
|
last = max(last_car, last_state)
|
|
if last <= first:
|
|
raise _Incomplete('Route has no observed duration')
|
|
duration = (last - first) / 1e9
|
|
if clocks_offset is not None:
|
|
start_time = first / 1e9 + clocks_offset
|
|
else:
|
|
start_time = _route_wall_time(route_id)
|
|
if start_time is not None:
|
|
result['startTime'] = start_time
|
|
result['endTime'] = start_time + duration
|
|
result['durationSeconds'] = round(duration, 3)
|
|
result['model'] = _model_label(model_outputs, model_loads, model)
|
|
if first_attention is not None and last_attention is not None and not attention_gap and \
|
|
first_attention - first <= 5 * 1e9 and last - last_attention <= 5 * 1e9:
|
|
result['distractedMoments'] = distracted
|
|
result['unresponsiveMoments'] = unresponsive
|
|
if car_pairs and not coverage_gap:
|
|
result['distanceMeters'] = round(distance, 2)
|
|
if state_pairs and not coverage_gap:
|
|
result['engagedSeconds'] = round(min(engaged, duration), 3)
|
|
result['engagedPercent'] = round(min(engaged, duration) / duration * 100, 1)
|
|
result.update(aol_statistics(control_samples, first, last, max_pair_seconds=MAX_PAIR_SECONDS))
|
|
if not end_of_route:
|
|
raise _Incomplete('End of route was not recorded')
|
|
if not car_pairs or not state_pairs or coverage_gap or \
|
|
max(first_car - first, first_state - first, last - last_car, last - last_state) > MAX_PAIR_SECONDS * 1e9:
|
|
raise _Incomplete('Route samples are missing or gapped')
|
|
if start_time is None:
|
|
raise _Incomplete('Route has no logged or named wall time')
|
|
result['complete'] = True
|
|
if model_outputs:
|
|
result['model'] = _model_label(model_outputs, model_loads or _rlog_model_loads(root, route, names, allowed), model)
|
|
return result
|
|
except (_Incomplete, LocalLogUnavailable, LogDecodeError, OSError, ValueError) as error:
|
|
result['reason'] = str(error) or 'Route analysis unavailable'
|
|
if (not prefer_rlog and result['reason'] in ('Route samples are missing or gapped', 'Route has no observed duration') and
|
|
isinstance(route, dict) and route.get('segments') and
|
|
all(segment.get('files', {}).get('rlog') is True for segment in route['segments']) and permitted()):
|
|
return analyze_route(root, route, permitted=allowed, prefer_rlog=True)
|
|
return result
|