mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-09-30 19:33:45 +08:00
349 lines
18 KiB
Python
349 lines
18 KiB
Python
"""Offline current-controller -> current-safety checks using the registered fleet routes.
|
|
|
|
No Panda connection, vehicle processes, or actuation. Missing evidence is not a pass.
|
|
Run with: python -m selfdrive.car.tests.fleet_safety --help
|
|
"""
|
|
import argparse
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import asdict
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from types import SimpleNamespace
|
|
import uuid
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
DEFAULT_OUT = Path(__file__).parent / 'fleet_results'
|
|
|
|
|
|
def digest(path):
|
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
|
|
|
|
def inventory():
|
|
from opendbc.car.values import PLATFORMS
|
|
from opendbc.car.tests.routes import routes, non_tested_cars
|
|
registered = defaultdict(list)
|
|
for route in routes:
|
|
registered[str(route.car_model)].append(dict(route=route.route, segment=route.segment))
|
|
exempt = set(map(str, non_tested_cars))
|
|
return [dict(platform=str(platform), routes=registered[str(platform)],
|
|
declared_without_route=str(platform) in exempt,
|
|
status='pending' if registered[str(platform)] else 'uncovered')
|
|
for platform in sorted(PLATFORMS)]
|
|
|
|
|
|
def build_safety(out, release=False):
|
|
source = ROOT / 'opendbc_repo/opendbc/safety/tests/libsafety/safety.c'
|
|
target = out / ('safety_release' if release else 'safety_debug')
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
command = [os.environ.get('CC', 'cc'), '-shared', '-fPIC', '-std=gnu11', '-Wall', '-Werror',
|
|
'-I', str(ROOT/'opendbc_repo'), str(source), '-o', str(target/'libsafety.so')]
|
|
if not release:
|
|
command.insert(1, '-DALLOW_DEBUG')
|
|
else:
|
|
# Some hooks declare constants only consumed by ALLOW_DEBUG branches.
|
|
# Keep the compiler diagnostic, without making a host-only warning prevent
|
|
# checking which configurations the release registry actually supports.
|
|
command.insert(1, '-Wno-error=unused-variable')
|
|
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
|
(target/'build.log').write_text(result.stdout + result.stderr)
|
|
result.check_returncode()
|
|
shutil.copy2(source.with_name('libsafety_py.py'), target/'libsafety_py.py')
|
|
headers = sorted((ROOT/'opendbc_repo/opendbc/safety').rglob('*.h'))
|
|
provenance = dict(command=command, release=release, library_sha256=digest(target/'libsafety.so'),
|
|
source_sha256=digest(source), headers={str(p.relative_to(ROOT)): digest(p) for p in headers})
|
|
(target/'build.json').write_text(json.dumps(provenance, indent=2))
|
|
return target
|
|
|
|
|
|
def load_safety(directory, panda_index):
|
|
# Each Panda gets independent C globals, never multiple handles to one library.
|
|
target = directory.parent / f'{directory.name}_panda_{panda_index}'
|
|
target.mkdir(exist_ok=True)
|
|
for name in ('libsafety.so', 'libsafety_py.py'):
|
|
shutil.copy2(directory/name, target/name)
|
|
spec = importlib.util.spec_from_file_location(f'fleet_safety_panda_{panda_index}', target/'libsafety_py.py')
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def test_toggles(scenario):
|
|
# Explicit fixture; do not read the operator's installed settings.
|
|
return SimpleNamespace(always_on_lateral=scenario != 'recorded', always_on_lateral_main=scenario == 'aol-main',
|
|
always_on_lateral_lkas=False, car_model='', cluster_offset=1.0,
|
|
disable_openpilot_long=False, force_fingerprint=False, lock_doors=False,
|
|
sng_hack=False, subaru_sng=False, subaru_sng_manual_parking_brake=False,
|
|
tesla_cooperative_steering=False, unlock_doors=False, vEgoStopping=0.5, volt_sng=False)
|
|
|
|
|
|
def read_route(route, segment, local_log=None):
|
|
from openpilot.tools.lib.logreader import LogReader, openpilotci_source
|
|
errors = []
|
|
for seg in ([segment] if segment is not None else [2, 1, 0]):
|
|
identifier = local_log or f'{route}/{seg}'
|
|
try:
|
|
selected = [m for m in LogReader(identifier, sources=[openpilotci_source], sort_by_time=True)
|
|
if m.which() in ('can', 'carControl', 'carParams')]
|
|
if not any(m.which() == 'carParams' for m in selected):
|
|
raise ValueError('No recorded CarParams')
|
|
if not any(m.which() == 'carControl' for m in selected):
|
|
raise ValueError('No recorded control requests; active coverage unavailable')
|
|
return selected, identifier
|
|
except Exception as error:
|
|
errors.append(f'{identifier}: {type(error).__name__}: {error}')
|
|
if local_log:
|
|
break
|
|
raise RuntimeError('; '.join(errors))
|
|
|
|
|
|
def run_case(platform, route, segment, scenario, safety_dir, local_log=None):
|
|
from opendbc.car import gen_empty_fingerprint
|
|
from opendbc.car.can_definitions import CanData
|
|
from opendbc.car.car_helpers import interfaces
|
|
from opendbc.car.fingerprints import MIGRATION
|
|
from opendbc.safety import ALTERNATIVE_EXPERIENCE
|
|
from openpilot.selfdrive.car.tests.fleet_safety_core import effective_safety_configs, TxAudit
|
|
|
|
messages, identifier = read_route(route, segment, local_log)
|
|
recorded = next(m.carParams for m in messages if m.which() == 'carParams')
|
|
detected = MIGRATION.get(recorded.carFingerprint, recorded.carFingerprint)
|
|
if detected != platform:
|
|
raise ValueError(f'Fixture identity mismatch: {detected} != {platform}')
|
|
fingerprint = gen_empty_fingerprint()
|
|
for msg in messages:
|
|
if msg.which() == 'can':
|
|
for frame in msg.can:
|
|
if frame.src < 64:
|
|
fingerprint.setdefault(frame.src, {})[frame.address] = len(frame.dat)
|
|
toggles = test_toggles(scenario)
|
|
interface = interfaces[platform]
|
|
cp = interface.get_params(platform, fingerprint, list(recorded.carFw),
|
|
recorded.openpilotLongitudinalControl, False, docs=False, starpilot_toggles=toggles)
|
|
fp = interface.get_starpilot_params(platform, fingerprint, list(recorded.carFw), cp, toggles)
|
|
if cp.dashcamOnly or cp.notCar:
|
|
return dict(status='uncovered', reason='Current interface is dashcamOnly/notCar', platform=platform, scenario=scenario)
|
|
if scenario == 'aol-main':
|
|
cp.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ALWAYS_ON_LATERAL
|
|
fp.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ALWAYS_ON_LATERAL
|
|
configs = effective_safety_configs(cp, fp)
|
|
audits = {}
|
|
modules = {}
|
|
for cfg in configs:
|
|
if cfg.safety_model in (0, 19): # SILENT/noOutput; any attempted TX below is a failure.
|
|
continue
|
|
module = load_safety(safety_dir, cfg.panda_index)
|
|
safety = module.libsafety
|
|
if safety.set_safety_hooks(cfg.safety_model, cfg.safety_param) != 0:
|
|
raise ValueError(f'Safety configuration unavailable in built library: {cfg}')
|
|
safety.init_tests()
|
|
safety.set_alternative_experience(cfg.alternative_experience)
|
|
modules[cfg.panda_index] = module
|
|
audits[cfg.panda_index] = TxAudit(safety, module.make_CANPacket, config=cfg)
|
|
ci = interface(cp, fp)
|
|
from inspect import getfile
|
|
imported = {getfile(interface), getfile(type(ci.CC)), getfile(type(ci.CS))}
|
|
for path in imported:
|
|
if not Path(path).resolve().is_relative_to(ROOT/'opendbc_repo/opendbc/car'):
|
|
raise RuntimeError('Controller imports outside the tested checkout: ' + path)
|
|
source_hashes = {str(Path(p).resolve().relative_to(ROOT)): digest(p) for p in imported}
|
|
first = messages[0].logMonoTime
|
|
pending = []
|
|
coverage = Counter()
|
|
errors = []
|
|
previous = None
|
|
previous_command = None
|
|
warmed = False
|
|
for msg in messages:
|
|
elapsed = (msg.logMonoTime-first)/1e9
|
|
if elapsed > 2 and not warmed:
|
|
# Keep safety/controller history established during startup, but report the
|
|
# scored interval separately from the explicit two-second fixture warmup.
|
|
audits = {cfg.panda_index: TxAudit(modules[cfg.panda_index].libsafety,
|
|
modules[cfg.panda_index].make_CANPacket, config=cfg)
|
|
for cfg in configs if cfg.panda_index in modules}
|
|
warmed = True
|
|
for module in modules.values():
|
|
module.libsafety.set_timer((msg.logMonoTime//1000) & 0xffffffff)
|
|
if msg.which() == 'can':
|
|
rx = [CanData(f.address, bytes(f.dat), f.src) for f in msg.can if f.src < 64]
|
|
pending.append((msg.logMonoTime, rx))
|
|
for frame in rx:
|
|
index = frame.src//4
|
|
if index not in modules:
|
|
continue
|
|
module = modules[index]
|
|
accepted = module.libsafety.safety_rx_hook(module.make_CANPacket(frame.address, frame.src-index*4, frame.dat))
|
|
if not accepted and elapsed > 2:
|
|
coverage['rx_rejections'] += 1
|
|
continue
|
|
if msg.which() != 'carControl':
|
|
continue
|
|
cs, _ = ci.update(pending, toggles)
|
|
pending.clear()
|
|
cc = msg.carControl.as_builder()
|
|
if previous_command is not None and msg.logMonoTime-previous_command > 100_000_000:
|
|
coverage['command_gaps_over_100ms'] += 1
|
|
previous_command = msg.logMonoTime
|
|
if scenario == 'aol-main':
|
|
# This is a controller/safety MAIN-availability probe, not a substitute for
|
|
# testing StarPilotCard's separate LKAS-button latch or full selfdrived loop.
|
|
cc.enabled = False
|
|
cc.longActive = False
|
|
cc.cruiseControl.cancel = False
|
|
cc.cruiseControl.resume = False
|
|
cc.latActive = bool(cs.canValid and cs.cruiseState.available and
|
|
str(cs.gearShifter) in ('drive', 'sport', 'low', 'eco', 'manumatic') and
|
|
not (cs.steerFaultTemporary or cs.steerFaultPermanent or cs.steeringDisengage or cs.brakePressed) and
|
|
(cp.steerAtStandstill or not cs.standstill))
|
|
state = (bool(cc.latActive), bool(cc.longActive), bool(cs.brakePressed), bool(cs.gasPressed), bool(cs.standstill))
|
|
if elapsed > 2:
|
|
coverage['control_frames'] += 1
|
|
coverage['lat_active_frames'] += bool(cc.latActive)
|
|
coverage['long_active_frames'] += bool(cc.longActive)
|
|
coverage['lat_only_frames'] += bool(cc.latActive and not cc.longActive)
|
|
coverage['brake_frames'] += bool(cs.brakePressed)
|
|
coverage['gas_frames'] += bool(cs.gasPressed)
|
|
coverage['standstill_frames'] += bool(cs.standstill)
|
|
coverage['invalid_carstate_frames'] += not cs.canValid
|
|
if previous is not None and previous[0] != state[0]:
|
|
coverage['lateral_transitions'] += 1
|
|
for module in modules.values():
|
|
module.libsafety.safety_tick_current_safety_config()
|
|
coverage['invalid_safety_rx_frames'] += not module.libsafety.safety_config_valid()
|
|
previous = state
|
|
_, frames = ci.apply(cc.as_reader(), msg.logMonoTime, toggles)
|
|
for addr, data, bus in frames:
|
|
if bus//4 not in audits:
|
|
errors.append(f'Unconfigured outgoing bus {bus} at {msg.logMonoTime}')
|
|
continue
|
|
audit = audits[bus//4]
|
|
audit.check(addr, bus, bytes(data), msg.logMonoTime, scenario,
|
|
requested_lat_active=bool(cc.latActive), requested_long_active=bool(cc.longActive))
|
|
summaries = {str(index): audit.summary() for index, audit in audits.items()}
|
|
missing = [name for name in ('lat_active_frames', 'lateral_transitions') if coverage[name] == 0]
|
|
if coverage['lat_active_frames'] < 100:
|
|
missing.append('at_least_100_active_control_frames')
|
|
if coverage['command_gaps_over_100ms']:
|
|
missing.append('continuous_recorded_control_timing')
|
|
if not summaries or any(s['status'] == 'uncovered' for s in summaries.values()):
|
|
missing.append('active_tx_authorization_and_request')
|
|
if scenario == 'aol-main' and coverage['lat_only_frames'] == 0:
|
|
missing.append('lat_only_frames')
|
|
if scenario == 'aol-main' and not any(s['requested_aol_only_tx'] for s in summaries.values()):
|
|
missing.append('requested_steering_with_aol_only_safety_permission')
|
|
failed = bool(errors or any(s['status'] == 'failed' for s in summaries.values()) or
|
|
any(coverage[k] for k in ('rx_rejections', 'invalid_carstate_frames', 'invalid_safety_rx_frames')))
|
|
status = 'failed' if failed else 'uncovered' if missing else 'pass'
|
|
return dict(platform=platform, route=identifier, scenario=scenario, status=status,
|
|
missing_coverage=missing, coverage=dict(coverage), errors=errors[:30],
|
|
safety_configs=[asdict(c) for c in configs], panda_results=summaries, source_hashes=source_hashes,
|
|
warmup_seconds=2,
|
|
configuration_policy='Recorded actuator requests under current default settings, or explicit AOL MAIN probe',
|
|
recorded_alternative_experience=int(recorded.alternativeExperience),
|
|
limitations=['Controller/safety only: no modeld, selfdrived/AOL latch, UI or physical ECU simulation',
|
|
'Uses recorded actuator requests; does not test new model outputs or every optional feature'])
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--inventory', action='store_true')
|
|
parser.add_argument('--platform', action='append', default=[])
|
|
parser.add_argument('--all', action='store_true')
|
|
parser.add_argument('--shard-count', type=int, default=1)
|
|
parser.add_argument('--shard-index', type=int, default=0)
|
|
parser.add_argument('--scenario', action='append', choices=['recorded', 'aol-main'])
|
|
parser.add_argument('--out', type=Path, default=DEFAULT_OUT)
|
|
parser.add_argument('--release', action='store_true', help='Build without ALLOW_DEBUG')
|
|
parser.add_argument('--local-log', help='Explicit local rlog, requires one selected platform')
|
|
parser.add_argument('--case-timeout', type=float, default=180)
|
|
parser.add_argument('--worker', type=Path, help=argparse.SUPPRESS)
|
|
args = parser.parse_args()
|
|
if args.shard_count < 1 or not 0 <= args.shard_index < args.shard_count:
|
|
parser.error('Require 0 <= shard-index < shard-count')
|
|
args.out = args.out.resolve()
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
if args.worker:
|
|
case = json.loads(args.worker.read_text())
|
|
# Isolate any Params access by interfaces from real operator settings.
|
|
with tempfile.TemporaryDirectory(prefix='fleet-params-') as params:
|
|
os.environ.update(PARAMS_ROOT=params, OPENPILOT_PREFIX='fleet-offline-'+str(os.getpid()))
|
|
try:
|
|
result = run_case(case['platform'], case['route'], case['segment'], case['scenario'],
|
|
Path(case['safety_dir']), case.get('local_log'))
|
|
except Exception as error:
|
|
import traceback
|
|
result = dict(status='error', error=repr(error), traceback=traceback.format_exc())
|
|
result['execution_id'] = case['execution_id']
|
|
args.worker.with_suffix('.result.json').write_text(json.dumps(result, indent=2))
|
|
return 0 if result['status'] == 'pass' else 1
|
|
entries = inventory()
|
|
report = dict(platforms=entries, total_platforms=len(entries),
|
|
with_routes=sum(bool(e['routes']) for e in entries),
|
|
registered_routes=sum(len(e['routes']) for e in entries),
|
|
scope='offline controller/safety compatibility; not full vehicle validation')
|
|
(args.out/'inventory.json').write_text(json.dumps(report, indent=2))
|
|
if args.inventory:
|
|
print(json.dumps({k:v for k,v in report.items() if k != 'platforms'}, indent=2))
|
|
return 0
|
|
if not args.all and not args.platform:
|
|
parser.error('Choose --all or --platform; --inventory only lists coverage')
|
|
unknown = set(args.platform)-{e['platform'] for e in entries}
|
|
if unknown:
|
|
parser.error('Unknown platforms: '+', '.join(sorted(unknown)))
|
|
if args.local_log and (len(args.platform) != 1 or args.all):
|
|
parser.error('--local-log requires exactly one --platform')
|
|
(args.out/'results.json').write_text(json.dumps(dict(cases=[], counts={}, state='running')))
|
|
try:
|
|
safety_dir = build_safety(args.out, args.release)
|
|
except (subprocess.SubprocessError, OSError) as error:
|
|
failure = dict(status='error', reason='Safety build failed', error=repr(error))
|
|
(args.out/'results.json').write_text(json.dumps(dict(cases=[failure], counts={'error': 1}), indent=2))
|
|
print(failure['reason'] + ': ' + failure['error'], flush=True)
|
|
return 1
|
|
selected = [e for e in entries if args.all or e['platform'] in args.platform]
|
|
selected = selected[args.shard_index::args.shard_count]
|
|
results = []
|
|
for entry in selected:
|
|
registered = entry['routes']
|
|
if args.local_log:
|
|
registered = [dict(route='local', segment=0)]
|
|
if not registered:
|
|
results.append(dict(platform=entry['platform'], status='uncovered', reason='No registered route'))
|
|
continue
|
|
for route in registered:
|
|
for scenario in args.scenario or ['recorded', 'aol-main']:
|
|
case = dict(platform=entry['platform'], **route, scenario=scenario, safety_dir=str(safety_dir),
|
|
local_log=args.local_log, execution_id=uuid.uuid4().hex)
|
|
path = args.out / f'case_{len(results):04d}.json'
|
|
path.write_text(json.dumps(case))
|
|
path.with_suffix('.result.json').unlink(missing_ok=True)
|
|
print(f"Checking {entry['platform']} {scenario} {route['route']}", flush=True)
|
|
with path.with_suffix('.log').open('w') as log:
|
|
try:
|
|
completed = subprocess.run([sys.executable, '-m', 'selfdrive.car.tests.fleet_safety', '--worker', str(path)],
|
|
cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=args.case_timeout, check=False)
|
|
result = json.loads(path.with_suffix('.result.json').read_text())
|
|
if result.get('execution_id') != case['execution_id'] or completed.returncode != (0 if result.get('status') == 'pass' else 1):
|
|
raise ValueError('Worker identity/exit status does not match its report')
|
|
except (subprocess.TimeoutExpired, OSError, ValueError) as error:
|
|
result = dict(status='error', error=repr(error))
|
|
results.append({**case, **result})
|
|
print(' '+result['status'], flush=True)
|
|
(args.out/'results.json').write_text(json.dumps(dict(cases=results, counts=dict(Counter(r['status'] for r in results))), indent=2))
|
|
counts = Counter(r['status'] for r in results)
|
|
(args.out/'results.json').write_text(json.dumps(dict(cases=results, counts=dict(counts)), indent=2))
|
|
print(json.dumps(dict(counts), indent=2))
|
|
return 0 if results and all(r['status'] == 'pass' for r in results) else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|