import numpy as np import pytest from typing import Any import openpilot.sunnypilot.models.helpers as helpers import openpilot.sunnypilot.models.runners.helpers as runner_helpers import openpilot.sunnypilot.modeld_v2.modeld as modeld_module ModelState = modeld_module.ModelState # These are the shapes extracted/loaded from the model onnx SHAPE_MODE_PARAMS = [ ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "nav_features": (1, 256), "nav_instructions": (1, 150)}, 'non20hz'), # Optimus Prime ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "lat_planner_state": (1, 4),}, 'non20hz'), # farmville ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), "lateral_control_params": (1, 2), "prev_desired_curv": (1, 100, 1)}, 'non20hz'), # wd40 ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), 'prev_desired_curv': (1, 100, 1), "lateral_control_params": (1, 2),}, 'non20hz'), # NTS ({'desire': (1, 25, 8), 'features_buffer': (1, 24, 512)}, '20hz'), # NPR ({'desire': (1, 100, 8), 'features_buffer': (1, 99, 512), 'prev_desired_curv': (1, 100, 1), "lateral_control_params": (1, 2),}, 'non20hz'), # NTS ({'desire': (1, 25, 8), 'features_buffer': (1, 25, 512)}, 'split'), # Steam Powered v2 ({'desire_pulse': (1, 25, 8), 'features_buffer': (1, 25, 512)}, 'split'), # desire rename ] # This creates a dummy runner, override, and bundle instance for the tests to run, without actually trying to load a physical model. class DummyOverride: def __init__(self, key: str, value: str) -> None: self.key = key self.value = value class DummyBundle: def __init__(self) -> None: self.overrides = [DummyOverride('lat', '.1'), DummyOverride('long', '.3')] self.generation = 10 # default to non-mlsim for buffer-update tests, as raising to 11 here will zero curvature buffer class DummyModelRunner: def __init__(self, input_shapes: dict[str, tuple[int, int, int]], constants: Any = None) -> None: self.input_shapes = input_shapes self.constants = constants or type('C', (), { 'FULL_HISTORY_BUFFER_LEN': 100, 'FEATURE_LEN': 512, 'DESIRE_LEN': 8, 'PREV_DESIRED_CURV_LEN': 1, 'INPUT_HISTORY_BUFFER_LEN': 25, 'TEMPORAL_SKIP': 4, })() self.vision_input_names: list[str] = [] shape = input_shapes.get('desire', (1, 0, 0)) # [batch, history, features] if shape[1] == 25: self.is_20hz = True else: self.is_20hz = False # Minimal prepare/run methods so ModelState can be run without actually running the model def prepare_inputs(self, numpy_inputs): return None def run_model(self): return { 'hidden_state': np.zeros((1, self.constants.FEATURE_LEN), dtype=np.float32), 'desired_curvature': np.zeros((1, 1), dtype=np.float32), } @pytest.fixture def shapes(request): return request.param @pytest.fixture def bundle() -> DummyBundle: return DummyBundle() @pytest.fixture def runner(shapes) -> DummyModelRunner: return DummyModelRunner(shapes) @pytest.fixture def apply_patches(monkeypatch: pytest.MonkeyPatch, bundle: DummyBundle, runner: DummyModelRunner): monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None: bundle, raising=False) monkeypatch.setattr(runner_helpers, 'get_model_runner', lambda: runner, raising=False) monkeypatch.setattr(modeld_module, 'get_model_runner', lambda: runner, raising=False) monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None: bundle, raising=False) # These are expected shapes and indices based on the time the model was presented def get_expected_indices(shape, constants, mode, key=None): if mode == 'split': start = -1 - (constants.TEMPORAL_SKIP * (constants.INPUT_HISTORY_BUFFER_LEN - 1)) arr = np.arange(constants.FULL_HISTORY_BUFFER_LEN) idxs = arr[start::constants.TEMPORAL_SKIP] return idxs elif mode == '20hz': num_elements = shape[1] step_size = int(-100 / num_elements) idxs = np.arange(step_size, step_size * (num_elements + 1), step_size)[::-1] return idxs elif mode == 'non20hz': return np.arange(shape[1]) return None @pytest.mark.parametrize("shapes,mode", SHAPE_MODE_PARAMS, indirect=["shapes"]) def test_buffer_shapes_and_indices(shapes, mode, apply_patches): state = ModelState() constants = DummyModelRunner(shapes).constants for key in shapes: buf = state.temporal_buffers.get(key, None) idxs = state.temporal_idxs_map.get(key, None) if buf is None: continue # not all shapes are 3D, and the non-3D ones are not buffered # Buffer shape logic if mode == 'split': expected_shape = (1, constants.FULL_HISTORY_BUFFER_LEN, shapes[key][2]) expected_idxs = get_expected_indices(shapes[key], constants, 'split', key) elif mode == '20hz': expected_shape = (1, constants.FULL_HISTORY_BUFFER_LEN, shapes[key][2]) expected_idxs = get_expected_indices(shapes[key], constants, '20hz', key) elif mode == 'non20hz': expected_shape = (1, shapes[key][1], shapes[key][2]) expected_idxs = get_expected_indices(shapes[key], constants, 'non20hz', key) assert buf is not None, f"{key}: buffer not found" assert buf.shape == expected_shape, f"{key}: buffer shape {buf.shape} != expected {expected_shape}" if expected_idxs is not None: assert np.all(idxs == expected_idxs), f"{key}: buffer idxs {idxs} != expected {expected_idxs}" else: assert idxs is None or idxs.size == 0, f"{key}: buffer idxs should be None or empty" def legacy_buffer_update(buf, new_val, mode, key, constants, idxs, input_shape, prev_desire=None): # This is what we compare the new dynamic logic to, to ensure it does the same thing if mode == 'split': if key == 'desire' or key.startswith('desire'): buf[0,:-1] = buf[0,1:] buf[0,-1] = new_val return buf.reshape((1, constants.INPUT_HISTORY_BUFFER_LEN, constants.TEMPORAL_SKIP, -1)).max(axis=2) elif key == 'features_buffer': buf[0,:-1] = buf[0,1:] buf[0,-1] = new_val return buf[0, idxs] elif key == 'prev_desired_curv': buf[0,:-1] = buf[0,1:] buf[0,-1,:] = new_val return buf[0, idxs] elif mode == '20hz': if key == 'desire': buf[:-1] = buf[1:] buf[-1] = new_val reshape_dims = (1, buf.shape[1], -1, buf.shape[2]) reshaped = buf.reshape(reshape_dims).max(axis=2) # Slice to last shape[1] elements to match model input shape input_len = reshaped.shape[1] model_input_len = 25 # For 20hz mode, desire shape[1] is 25 if input_len > model_input_len: reshaped = reshaped[:, -model_input_len:, :] return reshaped elif key == 'features_buffer': buffer_history_len = buf.shape[1] legacy_buf = np.zeros((buffer_history_len, buf.shape[2]), dtype=np.float32) legacy_buf[:] = buf[0] legacy_buf[:-1] = legacy_buf[1:] legacy_buf[-1] = new_val return legacy_buf[idxs] elif key == 'prev_desired_curv': buffer_history_len = buf.shape[1] legacy_buf = np.zeros((buffer_history_len, buf.shape[2]), dtype=np.float32) legacy_buf[:] = buf[0] legacy_buf[:-1] = legacy_buf[1:] legacy_buf[-1,:] = new_val return legacy_buf[idxs] elif mode == 'non20hz': if key == 'desire': desire_len = constants.DESIRE_LEN if prev_desire is None: prev_desire = np.zeros(desire_len, dtype=np.float32) # Set first element to zero new_val = new_val.copy() new_val[0] = 0 # Shift buffer by desire len buf[0][:-desire_len] = buf[0][desire_len:] # Only insert new desire if rising edge buf[0][-desire_len:] = np.where(new_val - prev_desire > 0.99, new_val, 0) prev_desire[:] = new_val return buf[0] elif key == 'features_buffer': buf[0, :-1] = buf[0, 1:] buf[0, -1] = new_val return buf[0, -input_shape[1]:] # (99, 512) elif key == 'prev_desired_curv': length = new_val.shape[0] buf[0,:-length,0] = buf[0,length:,0] buf[0,-length:,0] = new_val[:length] return buf[0] return None def dynamic_buffer_update(state, key, new_val, mode): if key == 'desire' or key.startswith('desire'): inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) for k, v in state.model_runner.input_shapes.items() if k != key} inputs[key] = new_val.copy() # ModelState.run expects desire as a pulse, so we zero the first element. inputs[key][0] = 0 state.run({}, {}, inputs, prepare_only=False) return state.numpy_inputs[key] if key == 'features_buffer': inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) for k, v in state.model_runner.input_shapes.items() if k != 'features_buffer'} def run_model_stub(): return { 'hidden_state': np.asarray(new_val, dtype=np.float32).reshape(1, -1), } state.model_runner.run_model = run_model_stub state.run({}, {}, inputs, prepare_only=False) return state.numpy_inputs['features_buffer'][0] if key == 'prev_desired_curv': inputs = {k: np.zeros(v[2], dtype=np.float32) if len(v) == 3 else np.zeros(v[1], dtype=np.float32) for k, v in state.model_runner.input_shapes.items() if k != 'prev_desired_curv'} def run_model_stub(): return { 'hidden_state': np.zeros((1, state.constants.FEATURE_LEN), dtype=np.float32), 'desired_curvature': np.asarray(new_val, dtype=np.float32).reshape(1, -1), } state.model_runner.run_model = run_model_stub state.run({}, {}, inputs, prepare_only=False) return state.numpy_inputs['prev_desired_curv'][0] return None @pytest.mark.parametrize("shapes,mode", SHAPE_MODE_PARAMS, indirect=["shapes"]) @pytest.mark.parametrize("key", ["desire", "features_buffer", "prev_desired_curv"]) def test_buffer_update_equivalence(shapes, mode, key, apply_patches): state = ModelState() if key == "desire": desire_keys = [k for k in shapes.keys() if k.startswith('desire')] if desire_keys: actual_key = desire_keys[0] # Use the first (and likely only) desire key else: actual_key = key if actual_key not in state.numpy_inputs: pytest.skip() constants = DummyModelRunner(shapes).constants buf = state.temporal_buffers.get(actual_key, None) idxs = state.temporal_idxs_map.get(actual_key, None) input_shape = shapes[actual_key] prev_desire = np.zeros(constants.DESIRE_LEN, dtype=np.float32) if key == 'desire' else None for step in range(20): # multiple steps to ensure history is built up new_val = np.full((input_shape[2],), step, dtype=np.float32) expected = legacy_buffer_update(buf, new_val, mode, actual_key, constants, idxs, input_shape, prev_desire) actual = dynamic_buffer_update(state, actual_key, new_val, mode) if expected is not None and actual is not None and expected.shape != actual.shape: if expected.ndim == 2 and actual.ndim == 2 and expected.shape[1] == actual.shape[1]: expected = expected[-actual.shape[0]:] assert np.allclose(actual, expected), f"{mode} {actual_key}: dynamic buffer update does not match legacy logic"