sunnypilot v2026.002.000

version: sunnypilot v2026.002.000 (staging)
date: 2026-07-21T07:44:02
master commit: 2c334ede443d7391d27575af8c854a095ba702a8
This commit is contained in:
github-actions[bot] committed 2026-07-21 07:44:02 +00:00
commit de77c89cf6
3995 files changed
+1260688

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
# Model Selector Version Compatibility
This document explains the version compatibility mechanism used by the Model Selector system, and the rationale behind certain version constraints and JSON file management strategies.
## Overview
The Model Selector is responsible for selecting and validating model bundles based on their metadata and version constraints. Each model bundle is distributed via a JSON file and includes a `minimumSelectorVersion` field indicating the minimum selector version required to load it.
To ensure robust compatibility and prevent mismatches between model expectations and selector capabilities, the selector enforces two version boundaries:
* **`REQUIRED_MIN_SELECTOR_VERSION`**: the oldest selector version we support.
* **`CURRENT_SELECTOR_VERSION`**: the current version of the selector logic.
## Version Compatibility Check
A model bundle is considered compatible if:
```python
REQUIRED_MIN_SELECTOR_VERSION <= bundle["minimumSelectorVersion"] <= CURRENT_SELECTOR_VERSION
```
This ensures:
* **Old bundles are rejected** if they rely on deprecated selector behavior.
* **Future bundles are ignored** if they expect logic that the current selector doesn't yet implement.
## Handling Breaking Changes
When a deep change in selector behavior requires *all* models to be recompiled (e.g., due to a major architectural update), we:
1. **Create a new JSON file** (e.g., from `models_v4.json` to `models_v5.json`).
2. **Assign updated `minimumSelectorVersion` values** in the new bundles.
This allows older selector versions to continue using the previous JSON file, while newer versions point to the new one, preventing cross-contamination.
## Why `REQUIRED_MIN_SELECTOR_VERSION` Still Matters
Despite using new JSON files to isolate breaking changes, `REQUIRED_MIN_SELECTOR_VERSION` plays a critical role:
### 1. **Cached Bundle Validation**
Model bundles are cached locally (e.g., in-memory or on disk). A user might have previously loaded a now-invalid bundle from an older JSON file.
`REQUIRED_MIN_SELECTOR_VERSION` prevents the selector from reloading or trusting that stale cached bundle, even if the original JSON is gone.
### 2. **Explicit Deprecation Boundary**
By raising `REQUIRED_MIN_SELECTOR_VERSION`, we declare older bundles officially unsupported, even if they technically still exist in a legacy JSON file.
### 3. **Avoiding Race Conditions**
Some clients may have intermittent access to updated JSONs. The runtime check ensures version compatibility is enforced independently of external file state.
## Summary
| Component | Purpose |
| ------------------------------- | --------------------------------------------------------------------- |
| `minimumSelectorVersion` | Declares the minimum selector version required to load a model bundle |
| `REQUIRED_MIN_SELECTOR_VERSION` | Prevents loading bundles that are too old (e.g., from stale cache) |
| `CURRENT_SELECTOR_VERSION` | Prevents loading bundles that are too new or forward-incompatible |
| JSON file renaming | Isolates bundles by selector generation to handle full recompiles |
This layered strategy ensures safe evolution of the model selection system while maintaining backward compatibility and runtime protection against stale or incompatible bundles.
Whitespace-only changes.
+121
View File
@@ -0,0 +1,121 @@
import numpy as np
def index_function(idx, max_val=192, max_idx=32):
return max_val * ((idx/max_idx)**2)
class ModelConstants:
# time and distance indices
IDX_N = 33
T_IDXS = [index_function(idx, max_val=10.0) for idx in range(IDX_N)]
X_IDXS = [index_function(idx, max_val=192.0) for idx in range(IDX_N)]
LEAD_T_IDXS = [0., 2., 4., 6., 8., 10.]
LEAD_T_OFFSETS = [0., 2., 4.]
META_T_IDXS = [2., 4., 6., 8., 10.]
# model inputs constants
MODEL_FREQ = 20
FEATURE_LEN = 512
HISTORY_BUFFER_LEN = 99
DESIRE_LEN = 8
TRAFFIC_CONVENTION_LEN = 2
NAV_FEATURE_LEN = 256
NAV_INSTRUCTION_LEN = 150
LAT_PLANNER_STATE_LEN = 4
LATERAL_CONTROL_PARAMS_LEN = 2
PREV_DESIRED_CURV_LEN = 1
# model outputs constants
FCW_THRESHOLDS_5MS2 = np.array([.05, .05, .15, .15, .15], dtype=np.float32)
FCW_THRESHOLDS_3MS2 = np.array([.7, .7], dtype=np.float32)
FCW_5MS2_PROBS_WIDTH = 5
FCW_3MS2_PROBS_WIDTH = 2
DISENGAGE_WIDTH = 5
POSE_WIDTH = 6
WIDE_FROM_DEVICE_WIDTH = 3
SIM_POSE_WIDTH = 6
LEAD_WIDTH = 4
LANE_LINES_WIDTH = 2
ROAD_EDGES_WIDTH = 2
PLAN_WIDTH = 15
DESIRE_PRED_WIDTH = 8
LAT_PLANNER_SOLUTION_WIDTH = 4
DESIRED_CURV_WIDTH = 1
NUM_LANE_LINES = 4
NUM_ROAD_EDGES = 2
LEAD_TRAJ_LEN = 6
DESIRE_PRED_LEN = 4
PLAN_MHP_N = 5
LEAD_MHP_N = 2
PLAN_MHP_SELECTION = 1
LEAD_MHP_SELECTION = 3
FCW_THRESHOLD_5MS2_HIGH = 0.15
FCW_THRESHOLD_5MS2_LOW = 0.05
FCW_THRESHOLD_3MS2 = 0.7
CONFIDENCE_BUFFER_LEN = 5
RYG_GREEN = 0.01165
RYG_YELLOW = 0.06157
POLY_PATH_DEGREE = 4
# model outputs slices
class Plan:
POSITION = slice(0, 3)
VELOCITY = slice(3, 6)
ACCELERATION = slice(6, 9)
T_FROM_CURRENT_EULER = slice(9, 12)
ORIENTATION_RATE = slice(12, 15)
class Meta:
ENGAGED = slice(0, 1)
# next 2, 4, 6, 8, 10 seconds
GAS_DISENGAGE = slice(1, 31, 6)
BRAKE_DISENGAGE = slice(2, 31, 6)
STEER_OVERRIDE = slice(3, 31, 6)
HARD_BRAKE_3 = slice(4, 31, 6)
HARD_BRAKE_4 = slice(5, 31, 6)
HARD_BRAKE_5 = slice(6, 31, 6)
# next 0, 2, 4, 6, 8, 10 seconds
GAS_PRESS = slice(31, 55, 4)
BRAKE_PRESS = slice(32, 55, 4)
LEFT_BLINKER = slice(33, 55, 4)
RIGHT_BLINKER = slice(34, 55, 4)
class MetaTombRaider:
ENGAGED = slice(0, 1)
# next 2, 4, 6, 8, 10 seconds
GAS_DISENGAGE = slice(1, 41, 8)
BRAKE_DISENGAGE = slice(2, 41, 8)
STEER_OVERRIDE = slice(3, 41, 8)
HARD_BRAKE_3 = slice(4, 41, 8)
HARD_BRAKE_4 = slice(5, 41, 8)
HARD_BRAKE_5 = slice(6, 41, 8)
GAS_PRESS = slice(7, 41, 8)
BRAKE_PRESS = slice(8, 41, 8)
# next 0, 2, 4, 6, 8, 10 seconds
LEFT_BLINKER = slice(41, 53, 2)
RIGHT_BLINKER = slice(42, 53, 2)
class MetaSimPose:
ENGAGED = slice(0, 1)
# next 2, 4, 6, 8, 10 seconds
GAS_DISENGAGE = slice(1, 36, 7)
BRAKE_DISENGAGE = slice(2, 36, 7)
STEER_OVERRIDE = slice(3, 36, 7)
HARD_BRAKE_3 = slice(4, 36, 7)
HARD_BRAKE_4 = slice(5, 36, 7)
HARD_BRAKE_5 = slice(6, 36, 7)
GAS_PRESS = slice(7, 36, 7)
# next 0, 2, 4, 6, 8, 10 seconds
LEFT_BLINKER = slice(36, 48, 2)
RIGHT_BLINKER = slice(37, 48, 2)
+61
View File
@@ -0,0 +1,61 @@
import argparse
import os
import hashlib
from openpilot.common.basedir import BASEDIR
from openpilot.sunnypilot import get_file_hash
from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL
DEFAULT_MODEL_NAME_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "model_name.py")
MODEL_HASH_PATH = os.path.join(BASEDIR, "openpilot", "sunnypilot", "models", "tests", "model_hash")
SUPERCOMBO_ONNX_PATH = os.path.join(BASEDIR, "openpilot", "selfdrive", "modeld", "models", "driving_supercombo.onnx")
def update_model_hash():
supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH)
combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest()
with open(MODEL_HASH_PATH, "w") as f:
f.write(combined_hash)
print(f"Generated and updated new combined model hash to {MODEL_HASH_PATH}")
def get_current_default_model_name():
print("[GET DEFAULT MODEL NAME]")
name = DEFAULT_MODEL
print(f'Current default model name: "{name}"')
return name
def update_default_model_name(name: str):
print("[CHANGE DEFAULT MODEL NAME]")
with open(DEFAULT_MODEL_NAME_PATH, "w") as f:
f.write(f'DEFAULT_MODEL = "{name}"\n')
print(f'New default model name: "{name}"')
print("[DONE]")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Update default model name and hash")
parser.add_argument("--new_name", type=str, help="New default model name")
args = parser.parse_args()
if not args.new_name:
print("Warning: No new default model name provided. Use --new_name to specify")
print("Default model name and hash will not be updated! (aborted)")
exit(0)
current_name = get_current_default_model_name()
new_name = args.new_name
if current_name == new_name:
print(f'Proposed default model name: "{new_name}"')
confirm = input("Proposed default model name is the same as the current default model name. Confirm? (y/n): ").upper().strip()
if confirm != "Y":
print("Default model name and hash will not be updated! (aborted)")
exit(0)
update_default_model_name(new_name)
update_model_hash()
+187
View File
@@ -0,0 +1,187 @@
"""
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 time
import requests
from requests.exceptions import (SSLError, RequestException, HTTPError)
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible
from openpilot.cereal import custom
class ModelParser:
"""Handles parsing of model data into cereal objects"""
@staticmethod
def _parse_download_uri(download_uri_data) -> custom.ModelManagerSP.DownloadUri:
download_uri = custom.ModelManagerSP.DownloadUri()
download_uri.uri = download_uri_data.get("url")
download_uri.sha256 = download_uri_data.get("sha256")
return download_uri
@staticmethod
def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact:
artifact = custom.ModelManagerSP.Artifact()
artifact.fileName = artifact_data.get("file_name")
artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {}))
return artifact
@staticmethod
def _parse_model(model_data) -> custom.ModelManagerSP.Model:
model = custom.ModelManagerSP.Model()
model.type = model_data.get("type")
model.artifact = ModelParser._parse_artifact(model_data.get("artifact", {}))
if metadata := model_data.get("metadata"):
model.metadata = ModelParser._parse_artifact(metadata)
return model
@staticmethod
def _parse_overrides(overrides_data: dict[str, str]) -> list[custom.ModelManagerSP.Override]:
overrides = []
for key, value in overrides_data.items():
override = custom.ModelManagerSP.Override()
override.key = key
override.value = value
overrides.append(override)
return overrides
@staticmethod
def _parse_bundle(bundle) -> custom.ModelManagerSP.ModelBundle:
model_bundle = custom.ModelManagerSP.ModelBundle()
model_bundle.index = int(bundle["index"])
model_bundle.internalName = bundle["short_name"]
model_bundle.displayName = bundle["display_name"]
model_bundle.models = [ModelParser._parse_model(model) for model in bundle.get("models",[])]
model_bundle.status = 0
model_bundle.generation = int(bundle["generation"])
model_bundle.environment = bundle["environment"]
model_bundle.runner = bundle.get("runner", custom.ModelManagerSP.Runner.snpe)
model_bundle.is20hz = bundle.get("is_20hz", False)
model_bundle.minimumSelectorVersion = int(bundle["minimum_selector_version"])
model_bundle.overrides = ModelParser._parse_overrides(bundle.get("overrides", {}))
model_bundle.ref = bundle.get("ref")
return model_bundle
@staticmethod
def parse_models(json_data: dict) -> list[custom.ModelManagerSP.ModelBundle]:
found_bundles = [ModelParser._parse_bundle(bundle) for bundle in json_data.get("bundles", [])]
return [bundle for bundle in found_bundles if is_bundle_version_compatible(bundle.to_dict())]
class ModelCache:
"""Handles caching of model data to avoid frequent remote fetches"""
def __init__(self, params: Params, cache_timeout: int = int(3600 * 1e9)):
self.params = params
self.cache_timeout = cache_timeout
self._LAST_SYNC_KEY = "ModelManager_LastSyncTime"
self._CACHE_KEY = "ModelManager_ModelsCache"
def _is_expired(self) -> bool:
"""Checks if the cache has expired"""
current_time = int(time.monotonic() * 1e9)
last_sync = self.params.get(self._LAST_SYNC_KEY) or 0
return bool(last_sync == 0) or (current_time - last_sync) >= self.cache_timeout
def get(self) -> tuple[dict, bool]:
"""
Retrieves cached model data and expiration status atomically.
Returns: Tuple of (cached_data, is_expired)
If no cached data exists or on error, returns an empty dict
"""
try:
cached_data = self.params.get(self._CACHE_KEY)
if not cached_data:
cloudlog.warning("No cached model data available")
return {}, True
return cached_data, self._is_expired()
except Exception as e:
cloudlog.exception(f"Error retrieving cached model data: {str(e)}")
return {}, True
def set(self, data: dict) -> None:
"""Updates the cache with new model data"""
self.params.put(self._CACHE_KEY, data, block=True)
self.params.put(self._LAST_SYNC_KEY, int(time.monotonic() * 1e9), block=True)
class ModelFetcher:
"""Handles fetching and caching of model data from remote source"""
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v17.json"
def __init__(self, params: Params):
self.params = params
self.model_cache = ModelCache(params)
self.model_parser = ModelParser()
def _fetch_and_cache_models(self) -> list[custom.ModelManagerSP.ModelBundle] | None:
"""Fetches fresh model data from remote and updates cache.
Returns None on transport errors. Raises on 404 and other fatal HTTP errors.
"""
try:
response = requests.get(self.MODEL_URL, timeout=10)
# Explicitly handle 404 differently
if response.status_code == 404:
cloudlog.error(f"Models URL returned 404 Not Found: {self.MODEL_URL}")
raise HTTPError(f"404 Not Found: {self.MODEL_URL}", response=response)
# Raise for any other 4xx/5xx
response.raise_for_status()
json_data = response.json()
self.model_cache.set(json_data)
cloudlog.debug("Successfully updated models cache")
return self.model_parser.parse_models(json_data)
except ConnectionError as e:
cloudlog.warning(f"DNS/connection error while fetching models: {e}")
except SSLError as e:
cloudlog.warning(f"SSL error while fetching models: {e}")
except RequestException as e:
cloudlog.warning(f"Request transport error while fetching models: {e}")
except Exception as e:
cloudlog.exception(f"Unexpected error fetching models: {e}")
return None
def get_available_bundles(self) -> list[custom.ModelManagerSP.ModelBundle]:
"""Gets the list of available models, with smart cache handling"""
cached_data, is_expired = self.model_cache.get()
if cached_data and not is_expired:
cloudlog.debug("Using valid cached models data")
return self.model_parser.parse_models(cached_data)
fetched_bundles = self._fetch_and_cache_models()
if fetched_bundles is not None:
return fetched_bundles
if not cached_data:
cloudlog.warning("Failed to fetch fresh data and no cache available")
cloudlog.warning("Failed to fetch fresh data. Using expired cache as fallback")
return self.model_parser.parse_models(cached_data)
if __name__ == "__main__":
params = Params()
model_fetcher = ModelFetcher(params)
bundles = model_fetcher.get_available_bundles()
for bundle in bundles:
for model in bundle.models:
model_overrides = {override.key: override.value for override in bundle.overrides}
# Print model details
print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}")
# Print artifact details
print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}")
# Print metadata details
print(f"Metadata: {model.metadata.fileName}, Download URI: {model.metadata.downloadUri.uri}")
+207
View File
@@ -0,0 +1,207 @@
"""
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 hashlib
import os
import pickle
from pathlib import Path
import numpy as np
from openpilot.cereal import custom
from openpilot.common.params import Params
from openpilot.common.swaglog import cloudlog
from openpilot.sunnypilot.models.constants import Meta, MetaSimPose, MetaTombRaider
from openpilot.common.hardware.hw import Paths
# SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO
REQUIRED_JSON_VERSION = 15
CUSTOM_MODEL_PATH = Paths.model_root()
METADATA_PATH = Path(__file__).parent / '../models/supercombo_metadata.pkl'
ModelManager = custom.ModelManagerSP
_LAST_VALIDATED_RAW = None
def _compute_hash(file_path: str) -> str | None:
from openpilot.common.file_chunker import open_file_chunked
try:
with open_file_chunked(file_path) as file:
return hashlib.file_digest(file, "sha256").hexdigest().lower()
except FileNotFoundError:
return None
async def verify_file(file_path: str, expected_hash: str) -> bool:
file_hash = _compute_hash(file_path)
return file_hash == expected_hash.lower() if file_hash else False
def _verify_file(file_path: str, expected_hash: str) -> bool:
file_hash = _compute_hash(file_path)
return file_hash == expected_hash.lower() if file_hash else False
def is_bundle_version_compatible(bundle: dict) -> bool:
"""
The bundle parsed from the json specifies a `minimum_selector_version`, which defines the minimum selector version
required to load the model. This function ensures that:
the bundle MUST match the `REQUIRED_JSON_VERSION` set here in helpers.
"""
return bundle.get("minimumSelectorVersion", 0) == REQUIRED_JSON_VERSION
def _bundle_artifacts(bundle: custom.ModelManagerSP.ModelBundle) -> list[tuple[str, str]]:
artifacts = []
for model in getattr(bundle, 'models', []) or []:
for artifact in (getattr(model, 'artifact', None), getattr(model, 'metadata', None)):
if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None):
sha256 = getattr(artifact.downloadUri, 'sha256', None)
if sha256:
artifacts.append((artifact.fileName, sha256))
return artifacts
def _bundle_is_valid_locally(bundle: custom.ModelManagerSP.ModelBundle) -> bool:
model_root = Paths.model_root()
return all(_verify_file(os.path.join(model_root, file_name), expected_hash)
for file_name, expected_hash in _bundle_artifacts(bundle))
def _bundle_needs_reset(active_bundle: custom.ModelManagerSP.ModelBundle, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None) -> bool:
if active_bundle is None:
return False
if available_bundles is not None:
matching_bundle = None
for bundle in available_bundles:
if getattr(active_bundle, 'ref', None) and getattr(bundle, 'ref', None):
if active_bundle.ref == bundle.ref:
matching_bundle = bundle
break
elif getattr(active_bundle, 'internalName', None) == getattr(bundle, 'internalName', None):
matching_bundle = bundle
break
if matching_bundle is None:
return True
if active_bundle.minimumSelectorVersion != matching_bundle.minimumSelectorVersion:
return True
active_runner = getattr(active_bundle, 'runner', None)
matching_runner = getattr(matching_bundle, 'runner', None)
if active_runner is not None and matching_runner is not None:
if getattr(active_runner, 'raw', active_runner) != getattr(matching_runner, 'raw', matching_runner):
return True
if set(_bundle_artifacts(active_bundle)) != set(_bundle_artifacts(matching_bundle)):
return True
return not _bundle_is_valid_locally(active_bundle)
def validate_active_bundle(params: Params, available_bundles: list[custom.ModelManagerSP.ModelBundle] | None = None) -> None:
global _LAST_VALIDATED_RAW
raw_bundle = params.get("ModelManager_ActiveBundle")
if not raw_bundle:
return
if raw_bundle == _LAST_VALIDATED_RAW:
return
active_bundle = get_active_bundle(params, raw_bundle_dict=raw_bundle)
if active_bundle is None or _bundle_needs_reset(active_bundle, available_bundles):
cloudlog.warning("Active model bundle invalid; resetting to default")
params.remove("ModelManager_ActiveBundle")
params.put("ModelRunnerTypeCache", int(custom.ModelManagerSP.Runner.stock), block=True)
_LAST_VALIDATED_RAW = None
else:
_LAST_VALIDATED_RAW = raw_bundle
def get_active_bundle(params: Params | None = None, raw_bundle_dict: dict | bytes | None = None) -> "custom.ModelManagerSP.ModelBundle | None":
params = params or Params()
try:
active_bundle_dict = raw_bundle_dict if raw_bundle_dict is not None else (params.get("ModelManager_ActiveBundle") or {})
if active_bundle_dict and is_bundle_version_compatible(active_bundle_dict):
return custom.ModelManagerSP.ModelBundle(**active_bundle_dict)
except Exception:
pass
return None
def get_active_model_runner(params: Params | None = None, force_check: bool = False) -> int:
params = params or Params()
cached_runner_type = params.get("ModelRunnerTypeCache")
if cached_runner_type is not None and not force_check:
return cached_runner_type
runner_type = custom.ModelManagerSP.Runner.stock
if active_bundle := get_active_bundle(params):
runner_type = active_bundle.runner.raw
if cached_runner_type != runner_type:
params.put("ModelRunnerTypeCache", int(runner_type), block=True)
return runner_type
def _get_model():
if bundle := get_active_bundle():
drive_model = next(model for model in bundle.models if model.type == ModelManager.Model.Type.supercombo)
return drive_model
return None
def load_metadata():
model = _get_model()
metadata_path = f"{CUSTOM_MODEL_PATH}/{model.metadata.fileName}" if model else METADATA_PATH
with open(metadata_path, 'rb') as f:
return pickle.load(f)
def prepare_inputs(model_metadata: dict) -> dict[str, np.ndarray]:
return {
key: np.zeros(shape, dtype=np.float32).flatten()
for key, shape in model_metadata['input_shapes'].items()
if 'img' not in key
}
def load_meta_constants(model_metadata: dict):
""" Loads the appropriate meta model class based on key shapes"""
if 'sim_pose' in model_metadata['input_shapes']:
return MetaSimPose
meta_slice = model_metadata['output_slices']['meta']
if (meta_slice.start, meta_slice.stop, meta_slice.step) == (5868, 5921, None):
return MetaTombRaider
return Meta
# The following method(s) are modeld helper methods
def plan_x_idxs_helper(constants, plan, model_output) -> list[float]:
# times at X_IDXS according to plan.
LINE_T_IDXS = [np.nan] * constants.IDX_N
LINE_T_IDXS[0] = 0.0
plan_x = model_output['plan'][0, :, plan.POSITION][:, 0].tolist()
for xidx in range(1, constants.IDX_N):
tidx = 0
# increment tidx until we find an element that's further away than the current xidx
while tidx < constants.IDX_N - 1 and plan_x[tidx + 1] < constants.X_IDXS[xidx]:
tidx += 1
if tidx == constants.IDX_N - 1:
# if the plan doesn't extend far enough, set plan_t to the max value (10s), then break
LINE_T_IDXS[xidx] = constants.T_IDXS[constants.IDX_N - 1]
break
# interpolate to find `t` for the current xidx
current_x_val = plan_x[tidx]
next_x_val = plan_x[tidx + 1]
p = (constants.X_IDXS[xidx] - current_x_val) / (next_x_val - current_x_val) if abs(
next_x_val - current_x_val) > 1e-9 else float('nan')
LINE_T_IDXS[xidx] = p * constants.T_IDXS[tidx + 1] + (1 - p) * constants.T_IDXS[tidx]
return LINE_T_IDXS
+298
View File
@@ -0,0 +1,298 @@
"""
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 asyncio
import os
import time
import aiohttp
from openpilot.common.params import Params
from openpilot.common.realtime import Ratekeeper
from openpilot.common.swaglog import cloudlog
from openpilot.common.hardware.hw import Paths
from openpilot.cereal import messaging, custom
from openpilot.sunnypilot.models.fetcher import ModelFetcher
from openpilot.sunnypilot.models.helpers import get_active_bundle, validate_active_bundle, verify_file
class ModelManagerSP:
"""Manages model downloads and status reporting"""
def __init__(self):
self.params = Params()
self.model_fetcher = ModelFetcher(self.params)
self.pm = messaging.PubMaster(["modelManagerSP"])
self.available_models: list[custom.ModelManagerSP.ModelBundle] = []
self.selected_bundle: custom.ModelManagerSP.ModelBundle = None
self.active_bundle: custom.ModelManagerSP.ModelBundle = get_active_bundle(self.params)
self._chunk_size = 128 * 1000 # 128 KB chunks
self._download_start_times: dict[str, float] = {} # Track start time per model
def _sync_artifact_progress(self, source_artifact) -> None:
"""Mirror download progress to all artifacts sharing the same filename in the selected bundle."""
if not self.selected_bundle:
return
for model in self.selected_bundle.models:
for artifact in (model.artifact, model.metadata):
if artifact is not source_artifact and artifact.fileName == source_artifact.fileName:
artifact.downloadProgress.status = source_artifact.downloadProgress.status
artifact.downloadProgress.progress = source_artifact.downloadProgress.progress
artifact.downloadProgress.eta = source_artifact.downloadProgress.eta
def _calculate_eta(self, filename: str, progress: float) -> int:
"""Calculate ETA based on elapsed time and current progress"""
if filename not in self._download_start_times or progress <= 0:
return 60 # Default ETA for new downloads
elapsed_time = time.monotonic() - self._download_start_times[filename]
if elapsed_time <= 0:
return 60
# If we're at X% after Y seconds, we can estimate total time as (Y / X) * 100
total_estimated_time = (elapsed_time / progress) * 100
eta = total_estimated_time - elapsed_time
return max(1, int(eta)) # Return at least 1 second if download is ongoing
async def _download_file(self, url: str, path: str, model) -> None:
"""Downloads a file with progress tracking"""
self._download_start_times[model.fileName] = time.monotonic()
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
bytes_downloaded = 0
with open(path, 'wb') as f:
async for chunk in response.content.iter_chunked(self._chunk_size): # type: bytes
f.write(chunk)
bytes_downloaded += len(chunk)
if self.params.get("ModelManager_DownloadIndex") is None:
raise Exception("Download cancelled")
if total_size > 0:
progress = (bytes_downloaded / total_size) * 100
model.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
model.downloadProgress.progress = progress
model.downloadProgress.eta = self._calculate_eta(model.fileName, progress)
self._sync_artifact_progress(model)
self._report_status()
# Clean up start time after download completes
del self._download_start_times[model.fileName]
async def _download_chunked(self, base_url: str, base_path: str, artifact) -> None:
from openpilot.common.file_chunker import get_manifest_path, get_chunk_name
manifest_url = get_manifest_path(base_url)
manifest_path = get_manifest_path(base_path)
async with aiohttp.ClientSession() as session:
async with session.get(manifest_url) as resp:
if resp.status == 404:
raise FileNotFoundError
resp.raise_for_status()
num_chunks = int((await resp.read()).strip())
self._download_start_times[artifact.fileName] = time.monotonic()
for i in range(num_chunks):
chunk_url = get_chunk_name(base_url, i, num_chunks)
chunk_path = get_chunk_name(base_path, i, num_chunks)
chunk_downloaded = 0
async with aiohttp.ClientSession() as session:
async with session.get(chunk_url) as response:
response.raise_for_status()
chunk_size = int(response.headers.get("content-length", 0))
with open(chunk_path, 'wb') as f:
async for data in response.content.iter_chunked(self._chunk_size):
f.write(data)
chunk_downloaded += len(data)
if self.params.get("ModelManager_DownloadIndex") is None:
raise Exception("Download cancelled")
intra = chunk_downloaded / max(chunk_size, 1)
progress = min(99, (i + intra) / num_chunks * 100)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
artifact.downloadProgress.progress = progress
artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress)
self._sync_artifact_progress(artifact)
self._report_status()
with open(manifest_path, 'w') as f:
f.write(str(num_chunks))
if os.path.isfile(base_path):
os.remove(base_path)
del self._download_start_times[artifact.fileName]
async def _process_artifact(self, artifact, destination_path: str) -> None:
if not artifact.downloadUri.uri:
return None
url = artifact.downloadUri.uri
expected_hash = artifact.downloadUri.sha256
filename = artifact.fileName
full_path = os.path.join(destination_path, filename)
try:
if await verify_file(full_path, expected_hash):
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
return
try:
await self._download_chunked(url, full_path, artifact)
except (FileNotFoundError, aiohttp.ClientResponseError):
await self._download_file(url, full_path, artifact)
if not await verify_file(full_path, expected_hash):
raise ValueError(f"Hash validation failed for {filename}")
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self._report_status()
except Exception as e:
cloudlog.error(f"Error downloading {filename}: {str(e)}")
for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]:
if os.path.isfile(f):
os.remove(f)
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
artifact.downloadProgress.eta = 0
self._sync_artifact_progress(artifact)
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
self._report_status()
self._download_start_times.pop(artifact.fileName, None)
raise
async def _process_model(self, model, destination_path: str) -> None:
"""Processes a single model download including verification"""
model_artifact = model.artifact
metadata_artifact = model.metadata
await self._process_artifact(metadata_artifact, destination_path)
await self._process_artifact(model_artifact, destination_path)
def _report_status(self) -> None:
"""Reports current status through messaging system"""
msg = messaging.new_message('modelManagerSP', valid=True)
model_manager_state = msg.modelManagerSP
if self.selected_bundle:
model_manager_state.selectedBundle = self.selected_bundle
if self.active_bundle:
model_manager_state.activeBundle = self.active_bundle
model_manager_state.availableBundles = self.available_models
self.pm.send('modelManagerSP', msg)
async def _download_bundle(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None:
"""Downloads all models in a bundle"""
self.selected_bundle = model_bundle
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.downloading
os.makedirs(destination_path, exist_ok=True)
try:
seen_artifacts: set[str] = set()
for model in self.selected_bundle.models:
for artifact in (model.metadata, model.artifact):
if not artifact.fileName:
continue
if artifact.fileName in seen_artifacts:
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
artifact.downloadProgress.progress = 100
artifact.downloadProgress.eta = 0
else:
seen_artifacts.add(artifact.fileName)
await self._process_artifact(artifact, destination_path)
self.active_bundle = self.selected_bundle
self.active_bundle.status = custom.ModelManagerSP.DownloadStatus.downloaded
self.params.put("ModelManager_ActiveBundle", self.active_bundle.to_dict(), block=True)
self.selected_bundle = None
except Exception:
self.selected_bundle.status = custom.ModelManagerSP.DownloadStatus.failed
raise
finally:
self._report_status()
def download(self, model_bundle: custom.ModelManagerSP.ModelBundle, destination_path: str) -> None:
"""Main entry point for downloading a model bundle"""
asyncio.run(self._download_bundle(model_bundle, destination_path))
def main_thread(self) -> None:
"""Main thread for model management"""
rk = Ratekeeper(1, print_delay_threshold=None)
while True:
try:
self.available_models = self.model_fetcher.get_available_bundles()
validate_active_bundle(self.params, self.available_models)
self.active_bundle = get_active_bundle(self.params)
if (index_to_download := self.params.get("ModelManager_DownloadIndex")) is not None:
if model_to_download := next((model for model in self.available_models if model.index == index_to_download), None):
try:
self.download(model_to_download, Paths.model_root())
except Exception as e:
cloudlog.exception(e)
finally:
self.params.remove("ModelManager_DownloadIndex")
self.selected_bundle = None
if self.params.get("ModelManager_ClearCache"):
self.clear_model_cache()
self.params.remove("ModelManager_ClearCache")
self._report_status()
rk.keep_time()
except Exception as e:
cloudlog.exception(f"Error in main thread: {str(e)}")
rk.keep_time()
def clear_model_cache(self) -> None:
"""
Clears the model cache directory of all files except those in the active model bundle.
"""
# Get list of files used by active model bundle
active_files = []
if self.active_bundle is not None: # When the default model is active
for model in self.active_bundle.models:
if hasattr(model, 'artifact') and model.artifact.fileName:
active_files.append(model.artifact.fileName)
if hasattr(model, 'metadata') and model.metadata.fileName:
active_files.append(model.metadata.fileName)
# Remove all files except active ones (including their chunk files)
model_dir = Paths.model_root()
try:
for filename in os.listdir(model_dir):
base = filename.split('.chunk')[0] if '.chunk' in filename else filename
if base not in active_files and filename not in active_files:
file_path = os.path.join(model_dir, filename)
if os.path.isfile(file_path):
os.remove(file_path)
cloudlog.info("Model cache cleared, keeping active model files")
except Exception as e:
cloudlog.exception(f"Error clearing model cache: {str(e)}")
def main():
ModelManagerSP().main_thread()
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
DEFAULT_MODEL = "CD210"
@@ -0,0 +1,15 @@
import os
import numpy as np
from openpilot.common.hardware.hw import Paths
from openpilot.cereal import custom
# Type definitions for clarity
NumpyDict = dict[str, np.ndarray]
ShapeDict = dict[str, tuple[int, ...]]
SliceDict = dict[str, slice]
ModelType = custom.ModelManagerSP.Model.Type
Model = custom.ModelManagerSP.Model
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
CUSTOM_MODEL_PATH = Paths.model_root()
@@ -0,0 +1,28 @@
from openpilot.sunnypilot.models.helpers import get_active_bundle
from openpilot.sunnypilot.models.runners.model_runner import ModelRunner
from openpilot.sunnypilot.models.runners.tinygrad.tinygrad_runner import TinygradRunner, TinygradSplitRunner
from openpilot.sunnypilot.models.runners.constants import ModelType
def get_model_runner() -> ModelRunner:
"""
Factory function to create and return the appropriate ModelRunner instance.
Selects TinygradRunner, choosing TinygradSplitRunner if separate vision/policy
models are detected in the active bundle.
:return: An instance of a ModelRunner subclass (ONNXRunner, TinygradRunner, or TinygradSplitRunner).
"""
bundle = get_active_bundle()
if bundle and bundle.models:
model_types = {m.type.raw for m in bundle.models}
# Check if the bundle uses separate vision and policy models (legacy or new split format)
split_types = {ModelType.vision, ModelType.policy, ModelType.offPolicy, ModelType.onPolicy}
if model_types & split_types:
return TinygradSplitRunner()
# Otherwise, assume a single model (likely supercombo)
if bundle.models:
return TinygradRunner(bundle.models[0].type.raw)
# Default fallback to TinygradRunner with the supercombo type if bundle info is missing/incomplete
return TinygradRunner(ModelType.supercombo)
@@ -0,0 +1,174 @@
from abc import abstractmethod, ABC
import numpy as np
from openpilot.sunnypilot.models.helpers import get_active_bundle
from openpilot.sunnypilot.models.runners.constants import NumpyDict, ShapeDict, Model, SliceDict, SEND_RAW_PRED
from openpilot.common.hardware.hw import Paths
import pickle
CUSTOM_MODEL_PATH = Paths.model_root()
class ModelData:
"""
Stores metadata and configuration for a specific machine learning model.
This class loads model metadata (like input shapes and output slices)
from a pickle file associated with a model instance.
:param model: The machine learning model object containing metadata.
"""
def __init__(self, model: Model):
self.model = model
self.metadata = model.metadata
self.input_shapes: ShapeDict = {}
self.output_slices: SliceDict = {}
if self.metadata:
self._load_metadata()
def _load_metadata(self) -> None:
"""Loads input shapes and output slices from the model's metadata pickle file."""
metadata_path = f"{CUSTOM_MODEL_PATH}/{self.metadata.fileName}"
with open(metadata_path, 'rb') as f:
model_metadata = pickle.load(f)
self.input_shapes = model_metadata.get('input_shapes', {})
self.output_slices = model_metadata.get('output_slices', {})
class ModularRunner(ABC):
"""
Represents a modular runner for handling and slicing model outputs.
This abstract base class is designed to provide an interface for modular
parsing and processing of model outputs. Classes inheriting from it must
implement the specified abstract methods, defining how model outputs
should be handled and stored. The primary goal is to enable structured
parsing of outputs through a dictionary-based method mapping.
:ivar parser_method_dict: Mapping dictionary containing parser methods
for handling specific types of outputs.
:type parser_method_dict: dict
"""
@property
@abstractmethod
def parser_method_dict(self) -> dict:
pass
@parser_method_dict.setter
@abstractmethod
def parser_method_dict(self, value: dict) -> None:
pass
@abstractmethod
def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
pass
class ModelRunner(ModularRunner):
"""
Abstract base class for managing and executing machine learning models.
Provides a common interface for loading models, preparing inputs, running
inference, and slicing/parsing outputs based on model metadata. Derived
classes implement the specifics of input preparation and model execution
for different frameworks (e.g., Tinygrad, ONNX).
"""
def __init__(self):
"""Initializes the model runner, loading the active model bundle."""
self.is_20hz: bool | None = None
self.is_20hz_3d: bool | None = None
self.models: dict[int, ModelData] = {}
self._model_data: ModelData | None = None # Active model data for current operation
self._parser_method_dict: dict = {}
self.inputs: dict = {}
self._parser = None
self._load_models()
self._constants = None
@property
def constants(self):
return self._constants
@property
def parser_method_dict(self) -> dict:
"""Returns the dictionary mapping model types to their respective parsing methods."""
return self._parser_method_dict
@parser_method_dict.setter
def parser_method_dict(self, value: dict) -> None:
"""Sets the dictionary mapping model types to their respective parsing methods."""
self._parser_method_dict = value
def _load_models(self) -> None:
"""Loads the active model bundle configuration and sets up ModelData."""
bundle = get_active_bundle()
if not bundle:
raise ValueError("No active model bundle found, why are we being executed?")
self.models = {model.type.raw: ModelData(model) for model in bundle.models}
self.is_20hz = bundle.is20hz
self.is_20hz_3d = False
@property
def input_shapes(self) -> ShapeDict:
"""Returns the input shapes for the currently active model."""
if self._model_data:
return self._model_data.input_shapes
raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
@property
def output_slices(self) -> SliceDict:
"""Returns the output slices for the currently active model."""
if self._model_data:
return self._model_data.output_slices
raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
@property
def vision_input_names(self) -> list[str]:
"""Returns the list of vision input names from the input shapes."""
if self._model_data:
return list(self._model_data.input_shapes.keys())
raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
@abstractmethod
def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict:
"""
Abstract method to prepare inputs for model inference.
:param numpy_inputs: Dictionary of numpy arrays for non-image inputs.
:return: Dictionary of prepared inputs ready for the model.
"""
raise NotImplementedError
@abstractmethod
def _run_model(self) -> NumpyDict:
"""
Abstract method to execute model inference with prepared inputs.
:return: Dictionary containing the model's raw output arrays.
"""
raise NotImplementedError
def _slice_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""
Slices the raw model output array based on the output_slices metadata.
:param model_outputs: The raw numpy array output from the model.
:return: A dictionary where keys are output names and values are sliced numpy arrays.
"""
if not self._model_data:
raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
sliced_outputs = {k: model_outputs[np.newaxis, v] for k, v in self._model_data.output_slices.items()}
if SEND_RAW_PRED:
sliced_outputs['raw_pred'] = model_outputs.copy() # Optionally include the full raw output
return sliced_outputs
def run_model(self) -> NumpyDict:
"""
Executes the model inference pipeline: runs the model and parses outputs.
:return: Dictionary containing the final parsed model outputs.
"""
return self._run_model() # Parsing is handled within specific runner implementations
@@ -0,0 +1,91 @@
import os
from abc import ABC
import numpy as np
from openpilot.sunnypilot.modeld_v2.parse_model_outputs import Parser as CombinedParser
from openpilot.sunnypilot.modeld_v2.parse_model_outputs_split import Parser as SplitParser
from openpilot.sunnypilot.models.runners.constants import ModelType, NumpyDict
from openpilot.sunnypilot.models.runners.model_runner import ModularRunner
from openpilot.common.hardware.hw import Paths
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
CUSTOM_MODEL_PATH = Paths.model_root()
class OffPolicyTinygrad(ModularRunner, ABC):
"""
A TinygradRunner specialized for off-policy models.
Uses a SplitParser to handle outputs specific to the off-policy part of a split model setup.
"""
def __init__(self):
self._off_policy_parser = SplitParser()
self.parser_method_dict[ModelType.offPolicy] = self._parse_off_policy_outputs
def _parse_off_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses off-policy model outputs using SplitParser."""
result: NumpyDict = self._off_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs))
return result
class OnPolicyTinygrad(ModularRunner, ABC):
"""
A TinygradRunner specialized for on-policy models.
Uses a SplitParser to handle outputs specific to the on-policy part of a split model setup.
"""
def __init__(self):
self._on_policy_parser = SplitParser()
self.parser_method_dict[ModelType.onPolicy] = self._parse_on_policy_outputs
def _parse_on_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses on-policy model outputs using SplitParser."""
result: NumpyDict = self._on_policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs))
return result
class PolicyTinygrad(ModularRunner, ABC):
"""
A TinygradRunner specialized for policy-only models.
Uses a SplitParser to handle outputs specific to the policy part of a split model setup.
"""
def __init__(self):
self._policy_parser = SplitParser()
self.parser_method_dict[ModelType.policy] = self._parse_policy_outputs
def _parse_policy_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses policy model outputs using SplitParser."""
result: NumpyDict = self._policy_parser.parse_policy_outputs(self._slice_outputs(model_outputs))
return result
class VisionTinygrad(ModularRunner, ABC):
"""
A TinygradRunner specialized for vision-only models.
Uses a SplitParser to handle outputs specific to the vision part of a split model setup.
"""
def __init__(self):
self._vision_parser = SplitParser()
self.parser_method_dict[ModelType.vision] = self._parse_vision_outputs
def _parse_vision_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses vision model outputs using SplitParser."""
result: NumpyDict = self._vision_parser.parse_vision_outputs(self._slice_outputs(model_outputs))
return result
class SupercomboTinygrad(ModularRunner, ABC):
"""
A TinygradRunner specialized for vision-only models.
Uses a SplitParser to handle outputs specific to the vision part of a split model setup.
"""
def __init__(self):
self._supercombo_parser = CombinedParser()
self.parser_method_dict[ModelType.supercombo] = self._parse_supercombo_outputs
def _parse_supercombo_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses vision model outputs using SplitParser."""
result: NumpyDict = self._supercombo_parser.parse_outputs(self._slice_outputs(model_outputs))
return result
@@ -0,0 +1,179 @@
import pickle
import numpy as np
from openpilot.sunnypilot.models.runners.constants import NumpyDict, ModelType, ShapeDict, CUSTOM_MODEL_PATH, SliceDict
from openpilot.sunnypilot.models.runners.model_runner import ModelRunner
from openpilot.sunnypilot.models.runners.tinygrad.model_types import PolicyTinygrad, VisionTinygrad, SupercomboTinygrad, OffPolicyTinygrad, OnPolicyTinygrad
from openpilot.sunnypilot.models.split_model_constants import SplitModelConstants
from openpilot.sunnypilot.modeld_v2.constants import ModelConstants
from tinygrad.tensor import Tensor
class TinygradRunner(ModelRunner, SupercomboTinygrad, PolicyTinygrad, VisionTinygrad, OffPolicyTinygrad, OnPolicyTinygrad):
"""
A ModelRunner implementation for executing Tinygrad models.
Handles loading Tinygrad model artifacts (.pkl), preparing inputs as Tinygrad
Tensors (potentially using QCOM extensions on TICI), running inference,
and parsing the outputs.
:param model_type: The type of model (e.g., supercombo) to load and run.
"""
def __init__(self, model_type: int = ModelType.supercombo):
ModelRunner.__init__(self)
SupercomboTinygrad.__init__(self)
PolicyTinygrad.__init__(self)
VisionTinygrad.__init__(self)
OffPolicyTinygrad.__init__(self)
OnPolicyTinygrad.__init__(self)
self._constants = ModelConstants
self._model_data = self.models.get(model_type)
if not self._model_data or not self._model_data.model:
raise ValueError(f"Model data for type {model_type} not available.")
artifact_filename = self._model_data.model.artifact.fileName
assert artifact_filename.endswith('_tinygrad.pkl'), \
f"Invalid model file {artifact_filename} for TinygradRunner"
model_pkl_path = f"{CUSTOM_MODEL_PATH}/{artifact_filename}"
with open(model_pkl_path, "rb") as f:
try:
# Load the compiled Tinygrad model runner function
self.model_run = pickle.load(f)
except FileNotFoundError as e:
# Provide a helpful error message if the model was built for a different platform
assert "/dev/kgsl-3d0" not in str(e), "Model was built on C3 or C3X, but is being loaded on PC"
raise
# Map input names to their required dtype and device from the loaded model
self.input_to_dtype = {}
self.input_to_device = {}
for idx, name in enumerate(self.model_run.captured.expected_names):
info = self.model_run.captured.expected_input_info[idx]
self.input_to_dtype[name] = info[2] # dtype
self.input_to_device[name] = info[3] # device
self._policy_cached = False
@property
def vision_input_names(self) -> list[str]:
"""Returns the list of vision input names from the input shapes."""
return [name for name in self.input_shapes.keys() if 'img' in name]
def prepare_policy_inputs(self, numpy_inputs: NumpyDict):
if not self._policy_cached:
for key, value in numpy_inputs.items():
self.inputs[key] = Tensor(value, device='NPY').realize()
self._policy_cached = True
def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict:
"""Prepares all vision and policy inputs for the model."""
self.prepare_policy_inputs(numpy_inputs)
for key in self.vision_input_names:
if key in self.inputs:
self.inputs[key] = self.inputs[key].cast(self.input_to_dtype[key])
return self.inputs
def _run_model(self) -> NumpyDict:
"""Runs the Tinygrad model inference and parses the outputs."""
outputs = self.model_run(**self.inputs).contiguous().realize().uop.base.buffer.numpy().flatten()
return self._parse_outputs(outputs)
def _parse_outputs(self, model_outputs: np.ndarray) -> NumpyDict:
"""Parses the raw model outputs using the standard Parser."""
if self._model_data is None:
raise ValueError("Model data is not available. Ensure the model is loaded correctly.")
result: NumpyDict = self.parser_method_dict[self._model_data.model.type.raw](model_outputs)
return result
class TinygradSplitRunner(ModelRunner):
"""
A ModelRunner that coordinates separate TinygradVisionRunner and TinygradPolicyRunner instances.
Manages the execution of split vision and policy models, combining their inputs and outputs.
"""
def __init__(self):
super().__init__()
self.is_20hz_3d = True
self.vision_runner = TinygradRunner(ModelType.vision)
self.policy_runner = TinygradRunner(ModelType.policy) if self.models.get(ModelType.policy) else None
self.off_policy_runner = TinygradRunner(ModelType.offPolicy) if self.models.get(ModelType.offPolicy) else None
self.on_policy_runner = TinygradRunner(ModelType.onPolicy) if self.models.get(ModelType.onPolicy) else None
self._constants = SplitModelConstants
def _run_model(self) -> NumpyDict:
"""Runs both vision and policy models and merges their parsed outputs."""
vision_output = self.vision_runner.run_model()
outputs = {**vision_output}
if self.policy_runner:
policy_output = self.policy_runner.run_model()
outputs.update(policy_output)
if self.off_policy_runner:
off_policy_output = self.off_policy_runner.run_model()
if self.on_policy_runner:
off_policy_output.pop('plan', None)
outputs.update(off_policy_output)
if self.on_policy_runner:
on_policy_output = self.on_policy_runner.run_model()
outputs.update(on_policy_output)
if 'planplus' in outputs and 'plan' in outputs:
outputs['plan'] = outputs['plan'] + outputs['planplus']
return outputs
@property
def vision_input_names(self) -> list[str]:
"""Returns the list of vision input names from the vision runner."""
return list(self.vision_runner.vision_input_names)
@property
def input_shapes(self) -> ShapeDict:
"""Returns the combined input shapes from both vision and policy models."""
shapes = {**self.vision_runner.input_shapes}
if self.policy_runner:
shapes.update(self.policy_runner.input_shapes)
if self.off_policy_runner:
shapes.update(self.off_policy_runner.input_shapes)
if self.on_policy_runner:
shapes.update(self.on_policy_runner.input_shapes)
return shapes
@property
def output_slices(self) -> SliceDict:
"""Returns the combined output slices from both vision and policy models."""
slices = {**self.vision_runner.output_slices}
if self.policy_runner:
slices.update(self.policy_runner.output_slices)
if self.off_policy_runner:
slices.update(self.off_policy_runner.output_slices)
if self.on_policy_runner:
slices.update(self.on_policy_runner.output_slices)
return slices
def prepare_inputs(self, numpy_inputs: NumpyDict) -> dict:
"""Prepares inputs for both vision and policy models."""
if self.policy_runner:
self.policy_runner.prepare_policy_inputs(numpy_inputs)
for key in self.vision_input_names:
if key in self.inputs:
self.vision_runner.inputs[key] = self.inputs[key].cast(self.vision_runner.input_to_dtype[key])
inputs = {**self.vision_runner.inputs}
if self.policy_runner:
inputs.update(self.policy_runner.inputs)
if self.off_policy_runner:
self.off_policy_runner.prepare_policy_inputs(numpy_inputs)
inputs.update(self.off_policy_runner.inputs)
if self.on_policy_runner:
self.on_policy_runner.prepare_policy_inputs(numpy_inputs)
inputs.update(self.on_policy_runner.inputs)
return inputs
@@ -0,0 +1,94 @@
import numpy as np
def index_function(idx, max_val=192, max_idx=32):
return max_val * ((idx/max_idx)**2)
class SplitModelConstants:
# time and distance indices
IDX_N = 33
T_IDXS = [index_function(idx, max_val=10.0) for idx in range(IDX_N)]
X_IDXS = [index_function(idx, max_val=192.0) for idx in range(IDX_N)]
LEAD_T_IDXS = [0., 2., 4., 6., 8., 10.]
LEAD_T_OFFSETS = [0., 2., 4.]
META_T_IDXS = [2., 4., 6., 8., 10.]
# model inputs constants
MODEL_FREQ = 20
HISTORY_FREQ = 5
HISTORY_LEN_SECONDS = 5
TEMPORAL_SKIP = MODEL_FREQ // HISTORY_FREQ
FULL_HISTORY_BUFFER_LEN = MODEL_FREQ * HISTORY_LEN_SECONDS
INPUT_HISTORY_BUFFER_LEN = HISTORY_FREQ * HISTORY_LEN_SECONDS
FEATURE_LEN = 512
DESIRE_LEN = 8
TRAFFIC_CONVENTION_LEN = 2
LAT_PLANNER_STATE_LEN = 4
LATERAL_CONTROL_PARAMS_LEN = 2
PREV_DESIRED_CURV_LEN = 1
# model outputs constants
FCW_THRESHOLDS_5MS2 = np.array([.05, .05, .15, .15, .15], dtype=np.float32)
FCW_THRESHOLDS_3MS2 = np.array([.7, .7], dtype=np.float32)
FCW_5MS2_PROBS_WIDTH = 5
FCW_3MS2_PROBS_WIDTH = 2
DISENGAGE_WIDTH = 5
POSE_WIDTH = 6
WIDE_FROM_DEVICE_WIDTH = 3
LEAD_WIDTH = 4
LANE_LINES_WIDTH = 2
ROAD_EDGES_WIDTH = 2
PLAN_WIDTH = 15
DESIRE_PRED_WIDTH = 8
LAT_PLANNER_SOLUTION_WIDTH = 4
DESIRED_CURV_WIDTH = 1
NUM_LANE_LINES = 4
NUM_ROAD_EDGES = 2
LEAD_TRAJ_LEN = 6
DESIRE_PRED_LEN = 4
PLAN_MHP_N = 5
LEAD_MHP_N = 2
PLAN_MHP_SELECTION = 1
LEAD_MHP_SELECTION = 3
FCW_THRESHOLD_5MS2_HIGH = 0.15
FCW_THRESHOLD_5MS2_LOW = 0.05
FCW_THRESHOLD_3MS2 = 0.7
CONFIDENCE_BUFFER_LEN = 5
RYG_GREEN = 0.01165
RYG_YELLOW = 0.06157
POLY_PATH_DEGREE = 4
# model outputs slices
class Plan:
POSITION = slice(0, 3)
VELOCITY = slice(3, 6)
ACCELERATION = slice(6, 9)
T_FROM_CURRENT_EULER = slice(9, 12)
ORIENTATION_RATE = slice(12, 15)
class Meta:
ENGAGED = slice(0, 1)
# next 2, 4, 6, 8, 10 seconds
GAS_DISENGAGE = slice(1, 31, 6)
BRAKE_DISENGAGE = slice(2, 31, 6)
STEER_OVERRIDE = slice(3, 31, 6)
HARD_BRAKE_3 = slice(4, 31, 6)
HARD_BRAKE_4 = slice(5, 31, 6)
HARD_BRAKE_5 = slice(6, 31, 6)
# next 0, 2, 4, 6, 8, 10 seconds
GAS_PRESS = slice(31, 55, 4)
BRAKE_PRESS = slice(32, 55, 4)
LEFT_BLINKER = slice(33, 55, 4)
RIGHT_BLINKER = slice(34, 55, 4)
Whitespace-only changes.
@@ -0,0 +1 @@
49133798d9cd9cacf47085c7ef8122bfee88cd9c6192a8314c81bfb1b37f5809
@@ -0,0 +1,20 @@
"""
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.
"""
from openpilot.cereal import messaging, custom
if __name__ == "__main__":
sm = messaging.SubMaster(["modelManagerSP"])
while True:
sm.update(500)
if sm.updated:
msg = sm["modelManagerSP"]
for model in msg.selectedBundle.models:
if model.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.downloading:
print("")
print(f"{model.fileName}: {model.downloadProgress}")
print("")
@@ -0,0 +1,22 @@
"""
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.
"""
from openpilot.sunnypilot import get_file_hash
from openpilot.sunnypilot.models.default_model import MODEL_HASH_PATH, SUPERCOMBO_ONNX_PATH
import hashlib
class TestDefaultModel:
def test_compare_onnx_hashes(self):
supercombo_hash = get_file_hash(SUPERCOMBO_ONNX_PATH)
combined_hash = hashlib.sha256(supercombo_hash.encode()).hexdigest()
with open(MODEL_HASH_PATH) as f:
current_hash = f.read().strip()
assert combined_hash == current_hash, "Run sunnypilot/models/default_model.py to update the default model name and hash"
@@ -0,0 +1,23 @@
import requests
from openpilot.sunnypilot.models.tinygrad_ref import get_tinygrad_ref
from openpilot.sunnypilot.models.fetcher import ModelFetcher
def fetch_tinygrad_ref():
response = requests.get(ModelFetcher.MODEL_URL, timeout=10)
response.raise_for_status()
json_data = response.json()
return json_data.get("tinygrad_ref")
def test_tinygrad_ref():
current_ref = get_tinygrad_ref()
remote_ref = fetch_tinygrad_ref()
assert remote_ref == current_ref, (
f"""tinygrad_repo ref does not match remote tinygrad_ref of current compiled driving models json.
Current: {current_ref}
Remote: {remote_ref}
Please run build-all workflow to update models."""
)
print("tinygrad_repo ref matches current compiled driving models json ref.")
@@ -0,0 +1,36 @@
import os
from openpilot.common.basedir import BASEDIR
def get_tinygrad_ref():
repo_path = os.path.join(BASEDIR, "tinygrad_repo")
git_path = os.path.join(repo_path, ".git")
try:
if os.path.isdir(git_path):
git_dir = git_path
else:
with open(git_path) as f:
line = f.read().strip()
git_dir = os.path.join(repo_path, line[8:])
with open(os.path.join(git_dir, "HEAD")) as f:
ref = f.read().strip()
if ref.startswith("ref:"):
with open(os.path.join(git_dir, ref.split(" ", 1)[1])) as f:
return f.read().strip()
return ref
except Exception as e:
print(f"Error getting tinygrad_repo ref: {e}")
return None
def main():
current_ref = get_tinygrad_ref()
if current_ref:
print(current_ref)
else:
print("")
if __name__ == "__main__":
main()