sunnypilot v2026.002.000
version: sunnypilot v2026.002.000 (staging) date: 2026-07-21T07:44:02 master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
commit
de77c89cf6
3995 files changed
+1260688
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import pickle
|
||||
import pytest
|
||||
|
||||
import openpilot.sunnypilot.models.helpers as helpers
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld_module
|
||||
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
|
||||
from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants
|
||||
|
||||
|
||||
class DummyOverride:
|
||||
def __init__(self, key, value):
|
||||
self.key = key
|
||||
self.value = value
|
||||
|
||||
|
||||
class DummyArtifact:
|
||||
def __init__(self, file_name):
|
||||
self.fileName = file_name
|
||||
|
||||
|
||||
class DummyModelType:
|
||||
def __init__(self, raw):
|
||||
self.raw = raw
|
||||
|
||||
|
||||
class DummyModel:
|
||||
def __init__(self, type_str, artifact_file):
|
||||
self.type = DummyModelType(type_str)
|
||||
self.artifact = DummyArtifact(artifact_file)
|
||||
|
||||
|
||||
class DummyBundle:
|
||||
def __init__(self, is_20hz=False, models=None, generation=10):
|
||||
self.overrides = [DummyOverride('lat', '.1'), DummyOverride('long', '.3')]
|
||||
self.generation = generation
|
||||
self.is20hz = is_20hz
|
||||
self.models = models or []
|
||||
|
||||
|
||||
class Archetype:
|
||||
def __init__(self, name, metadata_structure, model_stubs, is_20hz,
|
||||
expected_model_type, expected_constants_class, expected_parser_module,
|
||||
expected_desire_key):
|
||||
self.name = name
|
||||
self.metadata_structure = metadata_structure
|
||||
self.model_stubs = model_stubs
|
||||
self.is_20hz = is_20hz
|
||||
self.expected_model_type = expected_model_type
|
||||
self.expected_constants_class = expected_constants_class
|
||||
self.expected_parser_module = expected_parser_module
|
||||
self.expected_desire_key = expected_desire_key
|
||||
|
||||
|
||||
def _noop_jit(**kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def _make_vision_policy_metadata(vision_input_shapes, policy_input_shapes,
|
||||
vision_output_slices, policy_output_slices):
|
||||
return {
|
||||
'vision': {'input_shapes': vision_input_shapes, 'output_slices': vision_output_slices},
|
||||
'policy': {'input_shapes': policy_input_shapes, 'output_slices': policy_output_slices},
|
||||
}
|
||||
|
||||
|
||||
def _make_multi_policy_metadata(vision_input_shapes, policy_input_shapes,
|
||||
vision_output_slices, policy_output_slices):
|
||||
return {
|
||||
'vision': {'input_shapes': vision_input_shapes, 'output_slices': vision_output_slices},
|
||||
'offPolicy': {'input_shapes': policy_input_shapes, 'output_slices': policy_output_slices},
|
||||
}
|
||||
|
||||
|
||||
def _make_tri_policy_metadata(vision_input_shapes, policy_input_shapes,
|
||||
vision_output_slices, policy_output_slices):
|
||||
return {
|
||||
'vision': {'input_shapes': vision_input_shapes, 'output_slices': vision_output_slices},
|
||||
'onPolicy': {'input_shapes': policy_input_shapes, 'output_slices': policy_output_slices},
|
||||
'offPolicy': {'input_shapes': policy_input_shapes, 'output_slices': policy_output_slices},
|
||||
}
|
||||
|
||||
|
||||
def _make_supercombo_metadata(input_shapes, output_slices):
|
||||
return {'model': {'input_shapes': input_shapes, 'output_slices': output_slices}}
|
||||
|
||||
|
||||
SPLIT_VISION_INPUT_SHAPES = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256)}
|
||||
SPLIT_POLICY_INPUT_SHAPES = {'features_buffer': (1, 25, 512), 'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2)}
|
||||
SPLIT_VISION_SLICES = {'hidden_state': slice(0, 512), 'pose': slice(512, 524)}
|
||||
SPLIT_POLICY_SLICES = {'plan': slice(0, 495), 'meta': slice(495, 550)}
|
||||
|
||||
SUPERCOMBO_INPUT_SHAPES = {
|
||||
'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256),
|
||||
'desire': (1, 100, 8), 'features_buffer': (1, 99, 512),
|
||||
'lateral_control_params': (1, 2), 'prev_desired_curv': (1, 100, 1),
|
||||
'traffic_convention': (1, 2),
|
||||
}
|
||||
SUPERCOMBO_SLICES = {'plan': slice(0, 495), 'hidden_state': slice(495, 1007), 'meta': slice(1007, 1062)}
|
||||
|
||||
CAM_W, CAM_H = 1928, 1208
|
||||
|
||||
ARCHETYPES = {
|
||||
'vision_policy_split': Archetype(
|
||||
name='vision_policy_split',
|
||||
metadata_structure=_make_vision_policy_metadata(
|
||||
SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES,
|
||||
SPLIT_VISION_SLICES, SPLIT_POLICY_SLICES),
|
||||
model_stubs=[DummyModel('vision', 'driving_test_tinygrad.pkl'),
|
||||
DummyModel('policy', 'driving_test_tinygrad.pkl')],
|
||||
is_20hz=True,
|
||||
expected_model_type='split',
|
||||
expected_constants_class=SplitModelConstants,
|
||||
expected_parser_module='parse_model_outputs_split',
|
||||
expected_desire_key='desire',
|
||||
),
|
||||
'vision_multi_policy': Archetype(
|
||||
name='vision_multi_policy',
|
||||
metadata_structure=_make_multi_policy_metadata(
|
||||
SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES,
|
||||
SPLIT_VISION_SLICES, SPLIT_POLICY_SLICES),
|
||||
model_stubs=[DummyModel('vision', 'driving_test_tinygrad.pkl'),
|
||||
DummyModel('offPolicy', 'driving_test_tinygrad.pkl')],
|
||||
is_20hz=True,
|
||||
expected_model_type='multi_policy',
|
||||
expected_constants_class=SplitModelConstants,
|
||||
expected_parser_module='parse_model_outputs_split',
|
||||
expected_desire_key='desire',
|
||||
),
|
||||
'tri_policy': Archetype(
|
||||
name='tri_policy',
|
||||
metadata_structure=_make_tri_policy_metadata(
|
||||
SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES,
|
||||
SPLIT_VISION_SLICES, SPLIT_POLICY_SLICES),
|
||||
model_stubs=[DummyModel('vision', 'driving_test_tinygrad.pkl'),
|
||||
DummyModel('onPolicy', 'driving_test_tinygrad.pkl'),
|
||||
DummyModel('offPolicy', 'driving_test_tinygrad.pkl')],
|
||||
is_20hz=True,
|
||||
expected_model_type='multi_policy',
|
||||
expected_constants_class=SplitModelConstants,
|
||||
expected_parser_module='parse_model_outputs_split',
|
||||
expected_desire_key='desire',
|
||||
),
|
||||
'supercombo_non20hz': Archetype(
|
||||
name='supercombo_non20hz',
|
||||
metadata_structure=_make_supercombo_metadata(SUPERCOMBO_INPUT_SHAPES, SUPERCOMBO_SLICES),
|
||||
model_stubs=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')],
|
||||
is_20hz=False,
|
||||
expected_model_type='supercombo',
|
||||
expected_constants_class=ModelConstants,
|
||||
expected_parser_module='parse_model_outputs',
|
||||
expected_desire_key='desire',
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def make_pkl_data(archetype):
|
||||
return {
|
||||
'metadata': archetype.metadata_structure,
|
||||
(CAM_W, CAM_H): {'run_policy': _noop_jit, 'warp_enqueue': _noop_jit},
|
||||
}
|
||||
|
||||
|
||||
def write_pkl(tmp_path, archetype):
|
||||
pkl_path = tmp_path / 'driving_test_tinygrad.pkl'
|
||||
with open(pkl_path, 'wb') as f:
|
||||
pickle.dump(make_pkl_data(archetype), f)
|
||||
return pkl_path
|
||||
|
||||
|
||||
def make_bundle(archetype):
|
||||
return DummyBundle(
|
||||
models=archetype.model_stubs,
|
||||
is_20hz=archetype.is_20hz,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_modeld(monkeypatch):
|
||||
def _patch(bundle):
|
||||
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False)
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False)
|
||||
|
||||
return _patch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_state_factory(tmp_path, monkeypatch, patch_modeld):
|
||||
from openpilot.common.hardware import hw
|
||||
|
||||
def _create(archetype):
|
||||
write_pkl(tmp_path, archetype)
|
||||
bundle = make_bundle(archetype)
|
||||
patch_modeld(bundle)
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
return modeld_module.ModelState(cam_w=CAM_W, cam_h=CAM_H)
|
||||
|
||||
return _create
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS
|
||||
from openpilot.common.transformations.model import get_warp_matrix
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
|
||||
|
||||
class MockStruct:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return getattr(self, item)
|
||||
|
||||
|
||||
class TestCameraOffset:
|
||||
def setup_method(self):
|
||||
self.camera_offset = CameraOffsetHelper()
|
||||
self.dc = DEVICE_CAMERAS[('mici', 'os04c10')]
|
||||
|
||||
def test_smoothing(self):
|
||||
self.camera_offset.set_offset(0.2)
|
||||
|
||||
sm = MockStruct(
|
||||
deviceState=MockStruct(deviceType='mici'),
|
||||
roadCameraState=MockStruct(sensor='os04c10'),
|
||||
liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22])
|
||||
)
|
||||
|
||||
intrinsics_main = self.dc.fcam.intrinsics
|
||||
intrinsics_extra = self.dc.ecam.intrinsics
|
||||
device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32)
|
||||
extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32)
|
||||
|
||||
self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.02)
|
||||
self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_almost_equal(self.camera_offset.actual_camera_offset, 0.038)
|
||||
|
||||
def test_camera_offset_(self):
|
||||
intrinsics = self.dc.fcam.intrinsics
|
||||
transform = np.eye(3, dtype=np.float32)
|
||||
height = 1.22
|
||||
offset = 0.1
|
||||
|
||||
cy = intrinsics[1, 2]
|
||||
expected_shear = np.eye(3, dtype=np.float32)
|
||||
expected_shear[0, 1] = offset / height
|
||||
expected_shear[0, 2] = -offset / height * cy
|
||||
|
||||
result = CameraOffsetHelper.apply_camera_offset(transform, intrinsics, height, offset)
|
||||
np.testing.assert_array_almost_equal(result, expected_shear)
|
||||
|
||||
def test_update(self):
|
||||
sm = MockStruct(
|
||||
deviceState=MockStruct(deviceType='mici'),
|
||||
roadCameraState=MockStruct(sensor='os04c10'),
|
||||
liveCalibration=MockStruct(rpyCalib=[0.0, 0.0, 0.0], height=[1.22])
|
||||
)
|
||||
intrinsics_main = self.dc.fcam.intrinsics
|
||||
intrinsics_extra = self.dc.ecam.intrinsics
|
||||
device_from_calib_euler = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
||||
main_transform = get_warp_matrix(device_from_calib_euler, intrinsics_main, False).astype(np.float32)
|
||||
extra_transform = get_warp_matrix(device_from_calib_euler, intrinsics_extra, True).astype(np.float32)
|
||||
|
||||
self.camera_offset.set_offset(0.0) # test default offset doesn't change transformation
|
||||
main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
np.testing.assert_array_equal(main_out, main_transform)
|
||||
np.testing.assert_array_equal(extra_out, extra_transform)
|
||||
|
||||
self.camera_offset.set_offset(0.2) # test valid offset changes transformation
|
||||
main_out, extra_out = self.camera_offset.update(main_transform, extra_transform, sm, False)
|
||||
assert not np.array_equal(main_out, main_transform)
|
||||
assert not np.array_equal(extra_out, extra_transform)
|
||||
assert main_out[0, 1] != 0.0
|
||||
assert main_out[0, 2] != 0.0
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import openpilot.sunnypilot.models.helpers as helpers
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld_module
|
||||
from openpilot.sunnypilot.modeld_v2.modeld import _find_driving_pkl
|
||||
from openpilot.sunnypilot.modeld_v2.tests.conftest import DummyModel, DummyBundle, ARCHETYPES, CAM_W, CAM_H, \
|
||||
SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES
|
||||
|
||||
ModelState = modeld_module.ModelState
|
||||
|
||||
|
||||
# Pkl discovery
|
||||
|
||||
class TestFindDrivingPkl:
|
||||
def test_returns_none_when_no_bundle(self):
|
||||
assert _find_driving_pkl(None) is None
|
||||
|
||||
def test_returns_none_when_no_models(self):
|
||||
bundle = DummyBundle(models=[])
|
||||
assert _find_driving_pkl(bundle) is None
|
||||
|
||||
def test_returns_none_when_pkl_not_on_disk(self):
|
||||
bundle = DummyBundle(models=[
|
||||
DummyModel('vision', 'driving_fof_tinygrad.pkl'),
|
||||
DummyModel('policy', 'driving_fof_tinygrad.pkl'),
|
||||
])
|
||||
assert _find_driving_pkl(bundle) is None
|
||||
|
||||
def test_finds_pkl_by_artifact_name(self, tmp_path, monkeypatch):
|
||||
(tmp_path / 'driving_fof_tinygrad.pkl').write_bytes(b'fake')
|
||||
from openpilot.common.hardware import hw
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
|
||||
bundle = DummyBundle(models=[
|
||||
DummyModel('vision', 'driving_fof_tinygrad.pkl'),
|
||||
DummyModel('policy', 'driving_fof_tinygrad.pkl'),
|
||||
])
|
||||
result = _find_driving_pkl(bundle)
|
||||
assert result is not None
|
||||
assert 'driving_fof_tinygrad.pkl' in result
|
||||
|
||||
|
||||
# Init — assertion guard
|
||||
|
||||
class TestModelStateCombinedInit:
|
||||
def test_asserts_when_no_pkl(self, monkeypatch):
|
||||
bundle = DummyBundle(models=[], is_20hz=True)
|
||||
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False)
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False)
|
||||
with pytest.raises(AssertionError, match="No driving pkl found"):
|
||||
ModelState(cam_w=CAM_W, cam_h=CAM_H)
|
||||
|
||||
|
||||
class TestStockEquivalence:
|
||||
|
||||
def test_split_queue_keys_match_stock(self, model_state_factory):
|
||||
from openpilot.selfdrive.modeld.compile_modeld import make_input_queues
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip
|
||||
|
||||
state = model_state_factory(ARCHETYPES['vision_policy_split'])
|
||||
|
||||
frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, SPLIT_POLICY_INPUT_SHAPES)
|
||||
# action_t is a deep-model prerequisite the SP loader doesn't provide yet; see skip_keys below
|
||||
stock_shapes = {**SPLIT_VISION_INPUT_SHAPES, **SPLIT_POLICY_INPUT_SHAPES, 'action_t': (1, 2)}
|
||||
stock_queues, stock_npy = make_input_queues(stock_shapes, frame_skip, device='NPY')
|
||||
|
||||
# TODO-SP: remove action_t skip once SP adds prerequisite for deep models (action_t input queue)
|
||||
# prev_feat is a stock QCOM corruption workaround handled inside the SP loader's JIT path
|
||||
skip_keys = {'action_t', 'prev_feat'}
|
||||
# stock packs the per-key policy inputs into packed_npy_inputs; the npy views carry the individual keys
|
||||
stock_queue_keys = set(stock_queues.keys())
|
||||
if 'packed_npy_inputs' in stock_queue_keys:
|
||||
stock_queue_keys.remove('packed_npy_inputs')
|
||||
stock_queue_keys |= set(stock_npy.keys())
|
||||
assert set(state.input_queues.keys()) == stock_queue_keys - skip_keys, \
|
||||
f"Queue keys differ: v2={set(state.input_queues.keys())}, stock={stock_queue_keys}"
|
||||
assert set(state.numpy_inputs.keys()) == set(stock_npy.keys()) - skip_keys, \
|
||||
f"Npy keys differ: v2={set(state.numpy_inputs.keys())}, stock={set(stock_npy.keys())}"
|
||||
|
||||
def test_split_queue_keys_work_with_desire_key(self, model_state_factory):
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, make_split_input_queues
|
||||
|
||||
policy_shapes_desire = {'features_buffer': (1, 25, 512), 'desire': (1, 25, 8), 'traffic_convention': (1, 2)}
|
||||
frame_skip = derive_frame_skip(SPLIT_VISION_INPUT_SHAPES, policy_shapes_desire)
|
||||
queues, npy = make_split_input_queues(SPLIT_VISION_INPUT_SHAPES, policy_shapes_desire, frame_skip, device='NPY')
|
||||
|
||||
assert 'desire_q' in queues
|
||||
assert 'desire' in npy
|
||||
assert 'img_q' in queues
|
||||
assert 'feat_q' in queues
|
||||
|
||||
def test_split_vision_input_names_match_stock(self, model_state_factory):
|
||||
state = model_state_factory(ARCHETYPES['vision_policy_split'])
|
||||
assert state.vision_input_names == ['img', 'big_img']
|
||||
|
||||
def test_split_output_slices_preserved(self, model_state_factory):
|
||||
arch = ARCHETYPES['vision_policy_split']
|
||||
state = model_state_factory(arch)
|
||||
assert state.vision_output_slices == arch.metadata_structure['vision']['output_slices']
|
||||
assert state.policy_output_slices == arch.metadata_structure['policy']['output_slices']
|
||||
|
||||
|
||||
ARCHETYPE_NAMES = list(ARCHETYPES.keys())
|
||||
|
||||
|
||||
class TestModelTypeDetection:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_combined_model_type(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert state._combined_model_type == arch.expected_model_type, \
|
||||
f"{arch.name}: got {state._combined_model_type}, expected {arch.expected_model_type}"
|
||||
|
||||
|
||||
class TestConstantsSelection:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_constants_class(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert type(state.constants) is arch.expected_constants_class, \
|
||||
f"{arch.name}: got {type(state.constants).__name__}, expected {arch.expected_constants_class.__name__}"
|
||||
|
||||
|
||||
class TestParserSelection:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_parser_module(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
parser_module = type(state.parser).__module__
|
||||
assert parser_module.endswith(arch.expected_parser_module), \
|
||||
f"{arch.name}: parser from {parser_module}, expected module ending with {arch.expected_parser_module}"
|
||||
|
||||
|
||||
class TestDesireKeyDetection:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_desire_key(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert state.desire_key == arch.expected_desire_key, \
|
||||
f"{arch.name}: got {state.desire_key}, expected {arch.expected_desire_key}"
|
||||
|
||||
|
||||
class TestVisionInputNames:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_vision_names_contain_img(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert len(state.vision_input_names) >= 1
|
||||
for name in state.vision_input_names:
|
||||
assert 'img' in name, f"{arch.name}: vision input name '{name}' missing 'img'"
|
||||
|
||||
|
||||
class TestOutputSlices:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_vision_slices_populated(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert len(state.vision_output_slices) > 0, f"{arch.name}: vision_output_slices empty"
|
||||
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_policy_slices_match_type(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
if arch.expected_model_type == 'supercombo':
|
||||
assert state.policy_output_slices == {}, f"{arch.name}: supercombo should have empty policy slices"
|
||||
else:
|
||||
assert len(state.policy_output_slices) > 0, f"{arch.name}: split/multi should have policy slices"
|
||||
|
||||
|
||||
class TestInputQueueCreation:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_queues_not_empty(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert len(state.input_queues) > 0, f"{arch.name}: input_queues empty"
|
||||
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_npy_contains_transforms(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert 'tfm' in state.numpy_inputs, f"{arch.name}: 'tfm' missing from npy"
|
||||
assert 'big_tfm' in state.numpy_inputs, f"{arch.name}: 'big_tfm' missing from npy"
|
||||
assert state.numpy_inputs['tfm'].shape == (3, 3)
|
||||
assert state.numpy_inputs['big_tfm'].shape == (3, 3)
|
||||
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_npy_contains_desire(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert arch.expected_desire_key in state.numpy_inputs, \
|
||||
f"{arch.name}: '{arch.expected_desire_key}' missing from npy"
|
||||
|
||||
|
||||
class TestFrameBufferParams:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_frame_buf_params_per_vision_input(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
for name in state.vision_input_names:
|
||||
assert name in state.frame_buf_params, f"{arch.name}: frame_buf_params missing '{name}'"
|
||||
nv12_info = state.frame_buf_params[name]
|
||||
assert len(nv12_info) >= 4, f"{arch.name}: nv12_info for '{name}' too short"
|
||||
|
||||
|
||||
class TestBundleOverrides:
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_smoothing_params_from_overrides(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert state.LAT_SMOOTH_SECONDS == 0.1
|
||||
assert state.LONG_SMOOTH_SECONDS == 0.3
|
||||
|
||||
@pytest.mark.parametrize("archetype_name", ARCHETYPE_NAMES)
|
||||
def test_generation_from_bundle(self, archetype_name, model_state_factory):
|
||||
arch = ARCHETYPES[archetype_name]
|
||||
state = model_state_factory(arch)
|
||||
assert state.generation == 10
|
||||
|
||||
|
||||
class TestMlsimProperty:
|
||||
def test_mlsim_false_for_gen10(self, model_state_factory):
|
||||
state = model_state_factory(ARCHETYPES['supercombo_non20hz'])
|
||||
assert state.mlsim is False
|
||||
|
||||
def test_mlsim_true_for_gen11(self, tmp_path, monkeypatch, patch_modeld):
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl, ARCHETYPES as A
|
||||
|
||||
arch = A['supercombo_non20hz']
|
||||
write_pkl(tmp_path, arch)
|
||||
bundle = DummyBundle(models=arch.model_stubs, is_20hz=arch.is_20hz, generation=11)
|
||||
patch_modeld(bundle)
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
|
||||
state = ModelState(cam_w=CAM_W, cam_h=CAM_H)
|
||||
assert state.mlsim is True
|
||||
|
||||
|
||||
class TestCrossArchetypeMismatch:
|
||||
def test_wrong_is_20hz_changes_constants(self, tmp_path, monkeypatch, patch_modeld):
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.sunnypilot.modeld_v2.tests.conftest import write_pkl
|
||||
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
|
||||
|
||||
arch = ARCHETYPES['vision_policy_split']
|
||||
write_pkl(tmp_path, arch)
|
||||
bundle = DummyBundle(models=arch.model_stubs, is_20hz=False)
|
||||
patch_modeld(bundle)
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
|
||||
state = ModelState(cam_w=CAM_W, cam_h=CAM_H)
|
||||
assert type(state.constants) is ModelConstants, \
|
||||
"Wrong is_20hz should produce wrong constants class"
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key
|
||||
|
||||
|
||||
class TestDeriveFrameSkip:
|
||||
def test_non20hz_supercombo(self):
|
||||
vision = {}
|
||||
policy = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8)}
|
||||
assert derive_frame_skip(vision, policy) == 1
|
||||
|
||||
def test_20hz_supercombo(self):
|
||||
vision = {}
|
||||
policy = {'features_buffer': (1, 24, 512), 'desire': (1, 25, 8)}
|
||||
assert derive_frame_skip(vision, policy) == 4
|
||||
|
||||
def test_split_vision_policy(self):
|
||||
vision = {'img': (1, 12, 128, 256)}
|
||||
policy = {'features_buffer': (1, 25, 512), 'desire_pulse': (1, 25, 8)}
|
||||
assert derive_frame_skip(vision, policy) == 4
|
||||
|
||||
def test_no_features_buffer(self):
|
||||
assert derive_frame_skip({}, {}) == 1
|
||||
|
||||
|
||||
class TestFrameSkipBufferLengthEquivalence:
|
||||
@pytest.mark.parametrize("frame_skip,expected_buffer_length", [
|
||||
(1, 2),
|
||||
(4, 5),
|
||||
])
|
||||
def test_img_buffer_size_matches_warp_buffer_length(self, frame_skip, expected_buffer_length):
|
||||
n_frames = 2
|
||||
img_buf_dim0 = frame_skip * (n_frames - 1) + 1
|
||||
assert img_buf_dim0 == expected_buffer_length, \
|
||||
f"frame_skip={frame_skip}: img_buf[0]={img_buf_dim0}, expected {expected_buffer_length}"
|
||||
|
||||
@pytest.mark.parametrize("is_20hz,expected_frame_skip,expected_buffer_length", [
|
||||
(False, 1, 2),
|
||||
(True, 4, 5),
|
||||
])
|
||||
def test_is_20hz_to_frame_skip_to_buffer_length(self, is_20hz, expected_frame_skip, expected_buffer_length):
|
||||
if is_20hz:
|
||||
policy_shapes = {'features_buffer': (1, 24, 512)}
|
||||
else:
|
||||
policy_shapes = {'features_buffer': (1, 99, 512)}
|
||||
frame_skip = derive_frame_skip({}, policy_shapes)
|
||||
assert frame_skip == expected_frame_skip
|
||||
|
||||
n_frames = 2
|
||||
img_buf_dim0 = frame_skip * (n_frames - 1) + 1
|
||||
assert img_buf_dim0 == expected_buffer_length
|
||||
|
||||
|
||||
class TestTemporalSamplingEquivalence:
|
||||
def test_non20hz_desire_sampling_identity(self):
|
||||
buf = np.random.randn(100, 1, 8).astype(np.float32)
|
||||
frame_skip = 1
|
||||
sampled = buf[::frame_skip].reshape(-1, 8)
|
||||
assert sampled.shape == (100, 8)
|
||||
np.testing.assert_array_equal(sampled, buf[:, 0, :])
|
||||
|
||||
def test_20hz_desire_sampling_max(self):
|
||||
buf = np.zeros((100, 1, 8), dtype=np.float32)
|
||||
buf[99, 0, 3] = 1.0
|
||||
frame_skip = 4
|
||||
reshaped = buf.reshape(-1, frame_skip, 1, 8).max(axis=1)
|
||||
sampled = reshaped.reshape(-1, 8)
|
||||
assert sampled.shape == (25, 8)
|
||||
assert sampled[24, 3] == 1.0
|
||||
assert sampled[23, 3] == 0.0
|
||||
|
||||
def test_split_features_buffer_sampling_skip(self):
|
||||
buf = np.arange(100 * 512, dtype=np.float32).reshape(100, 1, 512)
|
||||
frame_skip = 4
|
||||
sampled = buf[::frame_skip].reshape(-1, 512)
|
||||
assert sampled.shape == (25, 512)
|
||||
np.testing.assert_array_equal(sampled[0], buf[0, 0])
|
||||
np.testing.assert_array_equal(sampled[1], buf[4, 0])
|
||||
np.testing.assert_array_equal(sampled[24], buf[96, 0])
|
||||
|
||||
def test_non20hz_features_buffer_sampling_identity(self):
|
||||
buf = np.arange(99 * 512, dtype=np.float32).reshape(99, 1, 512)
|
||||
frame_skip = 1
|
||||
sampled = buf[::frame_skip].reshape(-1, 512)
|
||||
assert sampled.shape == (99, 512)
|
||||
np.testing.assert_array_equal(sampled, buf[:, 0, :])
|
||||
|
||||
|
||||
class TestTemporalIdxEquivalence:
|
||||
@pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [
|
||||
('non20hz', (1, 100, 8), (1, 99, 512), 1),
|
||||
('20hz', (1, 25, 8), (1, 24, 512), 4),
|
||||
('split', (1, 25, 8), (1, 25, 512), 4),
|
||||
])
|
||||
def test_features_buffer_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip):
|
||||
history = fb_shape[1]
|
||||
|
||||
if mode == 'non20hz':
|
||||
modelstate_idxs = np.arange(history)
|
||||
buf_len = history
|
||||
elif mode == '20hz':
|
||||
buf_len = (history + 1) * 4
|
||||
step = int(-buf_len / history)
|
||||
modelstate_idxs = np.arange(step, step * (history + 1), step)[::-1]
|
||||
elif mode == 'split':
|
||||
buf_len = history * 4
|
||||
skip = buf_len // history
|
||||
modelstate_idxs = np.arange(buf_len)[-1 - (skip * (history - 1))::skip]
|
||||
|
||||
assert len(modelstate_idxs) == fb_shape[1], \
|
||||
f"{mode}: ModelState idx count {len(modelstate_idxs)} != input shape {fb_shape[1]}"
|
||||
|
||||
@pytest.mark.parametrize("mode,desire_shape,fb_shape,frame_skip", [
|
||||
('non20hz', (1, 100, 8), (1, 99, 512), 1),
|
||||
('20hz', (1, 25, 8), (1, 24, 512), 4),
|
||||
('split', (1, 25, 8), (1, 25, 512), 4),
|
||||
])
|
||||
def test_desire_idx_equivalence(self, mode, desire_shape, fb_shape, frame_skip):
|
||||
history = desire_shape[1]
|
||||
|
||||
compile_desire_buf_len = frame_skip * history if mode != 'non20hz' else history
|
||||
compile_sampled_count = compile_desire_buf_len // frame_skip if frame_skip > 1 else compile_desire_buf_len
|
||||
assert compile_sampled_count == history, \
|
||||
f"{mode}: compile desire samples {compile_sampled_count} != model input {history}"
|
||||
|
||||
|
||||
class TestDetectDesireKey:
|
||||
def test_finds_desire(self):
|
||||
shapes = {'features_buffer': (1, 99, 512), 'desire': (1, 100, 8), 'traffic_convention': (1, 2)}
|
||||
assert _detect_desire_key(shapes) == 'desire'
|
||||
|
||||
def test_finds_desire_pulse(self):
|
||||
shapes = {'features_buffer': (1, 25, 512), 'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2)}
|
||||
assert _detect_desire_key(shapes) == 'desire_pulse'
|
||||
|
||||
def test_returns_none_when_no_desire(self):
|
||||
shapes = {'features_buffer': (1, 99, 512), 'traffic_convention': (1, 2)}
|
||||
assert _detect_desire_key(shapes) is None
|
||||
|
||||
|
||||
class TestOutputSlicePreservation:
|
||||
def test_vision_hidden_state_slice_used_for_features(self):
|
||||
mock_slices = {'hidden_state': slice(0, 512), 'plan': slice(512, 1024)}
|
||||
features_slice = mock_slices['hidden_state']
|
||||
fake_output = np.random.randn(1, 1024).astype(np.float32)
|
||||
features = fake_output[:, features_slice]
|
||||
assert features.shape == (1, 512)
|
||||
|
||||
def test_policy_output_slices_independent(self):
|
||||
vision_slices = {'hidden_state': slice(0, 512)}
|
||||
policy_slices = {'plan': slice(0, 495), 'meta': slice(495, 550)}
|
||||
assert set(vision_slices.keys()) & set(policy_slices.keys()) == set(), \
|
||||
"vision and policy slices should not overlap in keys"
|
||||
@@ -0,0 +1,70 @@
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import log
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.constants import Plan
|
||||
from openpilot.sunnypilot.modeld_v2.modeld import ModelState
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld
|
||||
|
||||
|
||||
class MockStruct:
|
||||
def __init__(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
def test_recovery_power_scaling():
|
||||
state = MockStruct(
|
||||
PLANPLUS_CONTROL=0.75,
|
||||
LONG_SMOOTH_SECONDS=0.3,
|
||||
LAT_SMOOTH_SECONDS=0.1,
|
||||
MIN_LAT_CONTROL_SPEED=0.3,
|
||||
mlsim=True,
|
||||
generation=12,
|
||||
constants=MockStruct(T_IDXS=np.arange(100), DESIRE_LEN=8)
|
||||
)
|
||||
prev_action = log.ModelDataV2.Action()
|
||||
recorded_vel: list = []
|
||||
recorded_curv_plans: list = []
|
||||
|
||||
def mock_accel(plan_vel, plan_accel, t_idxs, action_t=0.0):
|
||||
recorded_vel.append(plan_vel.copy())
|
||||
return 0.0, False
|
||||
|
||||
def mock_curvature(output, plan, vego, lat_action_t, mlsim):
|
||||
recorded_curv_plans.append(plan.copy())
|
||||
return 0.0
|
||||
|
||||
modeld.get_accel_from_plan = mock_accel
|
||||
modeld.get_curvature_from_output = mock_curvature
|
||||
plan = np.random.rand(1, 100, 15).astype(np.float32)
|
||||
planplus = np.random.rand(1, 100, 15).astype(np.float32)
|
||||
merged_plan = plan + planplus
|
||||
|
||||
model_output: dict = {
|
||||
'plan': merged_plan.copy(),
|
||||
'planplus': planplus.copy()
|
||||
}
|
||||
|
||||
test_cases: list = [
|
||||
# (control, v_ego)
|
||||
(0.55, 20.0),
|
||||
(1.0, 25.0),
|
||||
(1.5, 25.1),
|
||||
(2.0, 20.0),
|
||||
(0.75, 19.0),
|
||||
(0.8, 25.1),
|
||||
]
|
||||
|
||||
for control, v_ego in test_cases:
|
||||
state.PLANPLUS_CONTROL = control
|
||||
recorded_vel.clear()
|
||||
recorded_curv_plans.clear()
|
||||
ModelState.get_action_from_model(state, model_output, prev_action, 0.0, 0.0, v_ego)
|
||||
|
||||
expected_accel_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + planplus[0, :, Plan.VELOCITY][:, 0]
|
||||
np.testing.assert_allclose(recorded_vel[0], expected_accel_plan_vel, rtol=1e-5, atol=1e-6)
|
||||
|
||||
# For the below, yes, I know this isn't the same slicing as fillmodlmsg. This is to show that the values are only scaled on curv
|
||||
expected_curv_plan_vel = plan[0, :, Plan.VELOCITY][:, 0] + control * planplus[0, :, Plan.VELOCITY][:, 0]
|
||||
np.testing.assert_allclose(recorded_curv_plans[0][:, Plan.VELOCITY][:, 0], expected_curv_plan_vel, rtol=1e-5, atol=1e-6)
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
os.environ['DEV'] = 'CPU'
|
||||
import pytest
|
||||
import numpy as np
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
from openpilot.sunnypilot.modeld_v2.warp import CAMERA_CONFIGS
|
||||
from openpilot.sunnypilot.modeld_v2.warp import Warp, MODEL_W, MODEL_H
|
||||
|
||||
VISION_NAME_PAIRS = [ # needed to account for supercombos input_imgs
|
||||
('img', 'big_img'),
|
||||
('input_imgs', 'big_input_imgs'),
|
||||
]
|
||||
|
||||
|
||||
class MockVisionBuf:
|
||||
def __init__(self, w, h):
|
||||
self.width = w
|
||||
self.height = h
|
||||
_, _, _, yuv_size = get_nv12_info(w, h)
|
||||
self.data = np.zeros(yuv_size, dtype=np.uint8)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("buffer_length", [2, 5])
|
||||
def test_warp_initialization(buffer_length):
|
||||
warp = Warp(buffer_length)
|
||||
assert warp.buffer_length == buffer_length
|
||||
assert warp.img_buffer_shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("buffer_length", [2, 5])
|
||||
@pytest.mark.parametrize("cam_w, cam_h", CAMERA_CONFIGS)
|
||||
@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS)
|
||||
def test_warp_process(buffer_length, cam_w, cam_h, road, wide):
|
||||
warp = Warp(buffer_length)
|
||||
mock_buf = MockVisionBuf(cam_w, cam_h)
|
||||
transform = np.eye(3, dtype=np.float32).flatten()
|
||||
bufs = {road: mock_buf, wide: mock_buf}
|
||||
transforms = {road: transform, wide: transform}
|
||||
|
||||
out = warp.process(bufs, transforms)
|
||||
assert isinstance(out, dict)
|
||||
assert road in out and wide in out
|
||||
assert out[road].shape == (1, 12, MODEL_H // 2, MODEL_W // 2)
|
||||
assert out[wide].shape == (1, 12, MODEL_H // 2, MODEL_W // 2)
|
||||
|
||||
key = (cam_w, cam_h)
|
||||
assert key in warp.jit_cache
|
||||
|
||||
out2 = warp.process(bufs, transforms)
|
||||
assert out2[road].shape == out[road].shape
|
||||
|
||||
|
||||
@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS)
|
||||
def test_warp_buffer_shift(road, wide):
|
||||
warp = Warp(2)
|
||||
cam_w, cam_h = CAMERA_CONFIGS[1]
|
||||
transform = np.eye(3, dtype=np.float32).flatten()
|
||||
|
||||
buf1 = MockVisionBuf(cam_w, cam_h)
|
||||
buf1.data[0] = 255
|
||||
bufs1 = {road: buf1, wide: buf1}
|
||||
transforms = {road: transform, wide: transform}
|
||||
out1 = warp.process(bufs1, transforms)
|
||||
road1 = out1[road].numpy().copy()
|
||||
|
||||
buf2 = MockVisionBuf(cam_w, cam_h)
|
||||
buf2.data[0] = 128
|
||||
bufs2 = {road: buf2, wide: buf2}
|
||||
out2 = warp.process(bufs2, transforms)
|
||||
assert not np.array_equal(road1, out2[road].numpy())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("buffer_length", [2, 5])
|
||||
@pytest.mark.parametrize("road, wide", VISION_NAME_PAIRS)
|
||||
def test_warp_buffer_accumulation(buffer_length, road, wide):
|
||||
warp = Warp(buffer_length)
|
||||
cam_w, cam_h = CAMERA_CONFIGS[0]
|
||||
transform = np.eye(3, dtype=np.float32).flatten()
|
||||
transforms = {road: transform, wide: transform}
|
||||
outputs = []
|
||||
|
||||
for i in range(buffer_length + 1):
|
||||
buf = MockVisionBuf(cam_w, cam_h)
|
||||
buf.data[:] = i * 10
|
||||
out = warp.process({road: buf, wide: buf}, transforms)
|
||||
outputs.append(out[road].numpy().copy())
|
||||
|
||||
assert warp.full_buffers['img'].shape == (buffer_length * 6, MODEL_H // 2, MODEL_W // 2)
|
||||
for i in range(1, len(outputs)):
|
||||
assert not np.array_equal(outputs[i - 1], outputs[i])
|
||||
|
||||
|
||||
def test_warp_different_cameras_same_instance():
|
||||
warp = Warp(2)
|
||||
transform = np.eye(3, dtype=np.float32).flatten()
|
||||
|
||||
buf1 = MockVisionBuf(*CAMERA_CONFIGS[0])
|
||||
warp.process({'img': buf1, 'big_img': buf1}, {'img': transform, 'big_img': transform})
|
||||
assert len(warp.jit_cache) == 1
|
||||
|
||||
buf2 = MockVisionBuf(*CAMERA_CONFIGS[1])
|
||||
warp.process({'img': buf2, 'big_img': buf2}, {'img': transform, 'big_img': transform})
|
||||
assert len(warp.jit_cache) == 2
|
||||
Reference in new issue
Block a user