Compare commits

..

33 Commits

Author SHA1 Message Date
royjr 06687fdd42 Merge branch 'master' into elantra-2024-port 2026-08-22 13:20:15 -04:00
royjr 1dc9835357 Merge branch 'master' into elantra-2024-port 2026-08-15 01:51:15 -04:00
royjr eb3508b2ad Update opendbc_repo 2026-08-15 01:51:11 -04:00
royjr c37462ff1b Merge branch 'master' into elantra-2024-port 2026-08-10 19:54:49 -04:00
royjr 405c7925fb Update opendbc_repo 2026-08-10 19:53:18 -04:00
royjr 2a69601b4e Merge branch 'master' into elantra-2024-port 2026-06-13 22:13:06 -04:00
royjr 39892c944e Update opendbc_repo 2026-06-13 22:12:46 -04:00
royjr 472b619a86 Merge branch 'master' into elantra-2024-port 2026-04-23 01:25:53 -04:00
royjr 91df73dd69 Update opendbc_repo 2026-04-23 01:25:45 -04:00
royjr 6f69d5640d Merge branch 'master' into elantra-2024-port 2026-03-11 23:29:46 -04:00
royjr 4df26493ee Update opendbc_repo 2026-03-11 23:29:37 -04:00
royjr 6b785ffb61 Merge branch 'master' into elantra-2024-port 2026-02-16 11:34:02 -05:00
royjr c2be6fb124 Update opendbc_repo 2026-02-16 11:33:49 -05:00
royjr 3ee30559b2 Merge branch 'master' into elantra-2024-port 2026-02-02 00:48:12 -05:00
royjr cb18829902 Update opendbc_repo 2026-01-24 13:01:44 -05:00
royjr 8a72573b3d Merge branch 'master' into elantra-2024-port 2026-01-24 12:36:32 -05:00
royjr 21405d6760 Update opendbc_repo 2026-01-24 12:36:24 -05:00
royjr 769edd9816 Update opendbc_repo 2026-01-15 19:57:04 -05:00
royjr 0dba93f586 Merge branch 'master' into elantra-2024-port 2026-01-15 19:56:45 -05:00
royjr 10716f6454 Merge branch 'master' into elantra-2024-port 2025-10-30 11:19:26 -04:00
royjr c87445980e Update opendbc_repo 2025-10-30 11:13:54 -04:00
royjr 9fee6dc3a1 Merge branch 'master' into elantra-2024-port 2025-10-18 07:32:44 -04:00
royjr 3b4b4b99e2 Merge branch 'master' into elantra-2024-port 2025-10-14 21:56:59 -04:00
royjr 7dee148821 Update opendbc_repo 2025-10-14 21:56:42 -04:00
royjr 980613a004 Merge branch 'master' into elantra-2024-port 2025-10-09 22:34:32 -04:00
royjr ad91c9c75e Update opendbc_repo 2025-10-09 22:34:23 -04:00
royjr 154ee95e99 Merge branch 'master' into elantra-2024-port 2025-10-02 09:22:06 -04:00
royjr a22aed37f5 Update opendbc_repo 2025-10-02 09:21:59 -04:00
royjr 56766273f0 Merge branch 'master' into elantra-2024-port 2025-09-30 14:42:18 -04:00
royjr 679a7ad82a Update opendbc_repo 2025-09-30 14:42:12 -04:00
royjr d546609306 Merge branch 'master' into elantra-2024-port 2025-08-22 12:09:05 -04:00
royjr 9eeb027a7d Update opendbc_repo 2025-08-22 12:08:56 -04:00
royjr b37b3fd657 Update opendbc_repo 2025-05-23 22:27:15 -04:00
256 changed files with 3184 additions and 12037 deletions
-1
View File
@@ -9,7 +9,6 @@
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text
*.wav filter=lfs diff=lfs merge=lfs -text
openpilot/selfdrive/assets/sounds/milestone.wav -filter -diff -merge -text
openpilot/selfdrive/car/tests/test_models_segs.txt filter=lfs diff=lfs merge=lfs -text
openpilot/common/hardware/comma/updater filter=lfs diff=lfs merge=lfs -text
+11
View File
@@ -0,0 +1,11 @@
* @sunnypilot/dev-internal
/.github/ @devtekve @sunnyhaibin
/release/ci/ @devtekve @sunnyhaibin
/tinygrad_repo @devtekve @Discountchubbs
/tinygrad/ @devtekve @Discountchubbs
/selfdrive/controls/lib/longitudinal_planner.py @devtekve @Discountchubbs
/selfdrive/controls/lib/longitudinal_mpc_lib/long_mpc.py @devtekve @Discountchubbs
/selfdrive/modeld/ @devtekve @Discountchubbs
/sunnypilot/model* @devtekve @Discountchubbs
/sunnypilot/sunnylink/ @devtekve
/system/athena/ @devtekve
@@ -8,13 +8,13 @@ on:
required: true
type: string
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: true
type: choice
default: 'qcom'
options:
- qcom
- chestnut
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
required: false
@@ -59,7 +59,7 @@ jobs:
id: get-json
run: |
cd docs/docs
PREFIX="driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_' || '' }}v"
PREFIX="driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_' || '' }}v"
latest=$(ls ${PREFIX}*.json | sed -E "s/${PREFIX}([0-9]+)\.json/\1/" | sort -n | tail -1)
next=$((latest+1))
json_file="${PREFIX}${next}.json"
@@ -78,7 +78,6 @@ jobs:
- name: Get next recompiled dir number
id: create-recompiled-dir
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_REPO: ${{ github.event.inputs.hf_repo }}
run: |
pip install huggingface_hub
@@ -0,0 +1,83 @@
name: Build default big model
on:
workflow_dispatch:
env:
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
jobs:
resolve_name:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.name.outputs.model_name }}
onnx_ref: ${{ steps.name.outputs.onnx_ref }}
steps:
- uses: actions/checkout@v4
- id: name
run: |
NAME=$(PYTHONPATH=${{ github.workspace }} python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
ONNX_REF=$(git log -1 --format='%H' -- openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx)
echo "model_name=${NAME}" >> $GITHUB_OUTPUT
echo "onnx_ref=$ONNX_REF" >> $GITHUB_OUTPUT
build_model:
needs: resolve_name
uses: ./.github/workflows/sunnypilot-build-model.yaml
with:
upstream_branch: ${{ needs.resolve_name.outputs.onnx_ref }}
custom_name: ${{ needs.resolve_name.outputs.model_name }}
target_hardware: usbgpu
secrets: inherit
upload_defaults:
needs: [ resolve_name, build_model ]
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Download artifact name
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve_name.outputs.model_name }}
path: artifact_name
- name: Read artifact name
id: artifact
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: output
- name: Upload to HF and update default_models.json
env:
HF_OIDC_RESOURCE: datasets/${{ env.HF_REPO }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
export PYTHONPATH=$(pwd)
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ env.HF_DEFAULTS_PATH }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--onnx-path "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" \
--onnx-ref "${{ needs.resolve_name.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve_name.outputs.model_name }}" \
--tinygrad-ref "$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)" \
--run-number "${{ github.run_number }}"
-522
View File
@@ -1,522 +0,0 @@
name: Build default models
on:
workflow_dispatch:
inputs:
target:
description: 'Model target to build'
required: true
type: choice
options:
- small
- big
- dm
workflow_call:
inputs:
target:
description: 'Model target to build (small, big, or dm)'
required: true
type: string
concurrency:
group: build-default-models-${{ inputs.target }}
cancel-in-progress: false
env:
HF_REPO: sunnypilot/sunnypilot_models_v1
jobs:
resolve:
runs-on: ubuntu-24.04
outputs:
model_name: ${{ steps.resolve.outputs.model_name }}
safe_model_name: ${{ steps.resolve.outputs.safe_model_name }}
onnx_ref: ${{ steps.resolve.outputs.onnx_ref }}
onnx_path: ${{ steps.resolve.outputs.onnx_path }}
hf_defaults_path: ${{ steps.resolve.outputs.hf_defaults_path }}
tinygrad_ref: ${{ steps.resolve.outputs.tinygrad_ref }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- id: resolve
run: |
export PYTHONPATH=${{ github.workspace }}
if [ "${{ inputs.target }}" = "big" ]; then
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/big"
elif [ "${{ inputs.target }}" = "dm" ]; then
ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx"
HF_DEFAULTS_PATH="models/defaults/dm"
NAME="dmonitoring_model ($(git log -1 --format=%cd --date=format:'%B %d, %Y' -- "$ONNX_PATH"))"
else
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_MODEL; print(DEFAULT_MODEL)")
ONNX_PATH="openpilot/selfdrive/modeld/models/driving_supercombo.onnx"
HF_DEFAULTS_PATH="models/defaults/small"
fi
ONNX_REF=$(git log -1 --format='%H' -- "$ONNX_PATH")
TINYGRAD_REF=$(python3 openpilot/sunnypilot/models/tinygrad_ref.py)
if [ -z "$TINYGRAD_REF" ]; then
echo "::error::Failed to resolve tinygrad ref"
exit 1
fi
SAFE_NAME="${NAME// /-}"
echo "model_name=${NAME}" >> $GITHUB_OUTPUT
echo "safe_model_name=${SAFE_NAME}" >> $GITHUB_OUTPUT
echo "onnx_ref=${ONNX_REF}" >> $GITHUB_OUTPUT
echo "onnx_path=${ONNX_PATH}" >> $GITHUB_OUTPUT
echo "hf_defaults_path=${HF_DEFAULTS_PATH}" >> $GITHUB_OUTPUT
echo "tinygrad_ref=${TINYGRAD_REF}" >> $GITHUB_OUTPUT
build_small_model:
needs: resolve
if: ${{ inputs.target == 'small' }}
runs-on: [self-hosted, tici]
env:
SMALL_ONNX: openpilot/selfdrive/modeld/models/driving_supercombo.onnx
SMALL_PKL: openpilot/selfdrive/modeld/models/driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ env.SMALL_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile small model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.SMALL_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.SMALL_PKL }}
- name: Chunk small pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/small_output"
PKL_BASE="driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload small model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/small_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
path: ${{ github.workspace }}/small_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
build_big_model:
needs: resolve
if: ${{ inputs.target == 'big' }}
runs-on: [self-hosted, chestnut]
env:
BIG_ONNX: openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx
BIG_PKL: openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull big ONNX via LFS
run: git lfs pull -I "${{ env.BIG_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Wait for chestnut PCIe link
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
python3 -c "
import time
from openpilot.system.hardware.chestnut.flash import link_up
for i in range(10):
if link_up():
print(f'PCIe link up after {i+1} attempt(s)')
break
time.sleep(1)
else:
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
"
- name: Compile big model with stock compiler
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
FRAME_SKIP=$(python3 -c "from openpilot.selfdrive.modeld.constants import ModelConstants as MC; print(MC.MODEL_RUN_FREQ // MC.MODEL_CONTEXT_FREQ)")
TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
env ${TG_FLAGS} python3 \
${{ github.workspace }}/openpilot/selfdrive/modeld/compile_modeld.py \
--onnx ${{ github.workspace }}/${{ env.BIG_ONNX }} \
--model-size $MODEL_SIZE \
--camera-resolutions $CAMERA_RES \
--frame-skip $FRAME_SKIP \
--output ${{ github.workspace }}/${{ env.BIG_PKL }}
- name: Chunk big pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked into {len(targets)} files')
"
- name: Prepare output
env:
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
OUTPUT_DIR="${{ github.workspace }}/big_output"
PKL_BASE="big_driving_tinygrad.pkl"
mkdir -p "$OUTPUT_DIR"
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
--model-dir "$MODELS_DIR" \
--output-dir "$OUTPUT_DIR" \
--custom-name "$MODEL_NAME" \
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}"
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
- name: Upload big model artifact
uses: actions/upload-artifact@v4
with:
name: model-${{ needs.resolve.outputs.safe_model_name }}-${{ github.run_number }}
path: ${{ github.workspace }}/big_output/
- name: Upload artifact name file
uses: actions/upload-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
path: ${{ github.workspace }}/big_output/artifact_name.txt
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
upload_defaults:
needs: [ resolve, build_small_model, build_big_model, build_dm_model ]
if: |
${{
!cancelled() &&
(inputs.target == 'big' && needs.build_big_model.result == 'success' ||
inputs.target == 'small' && needs.build_small_model.result == 'success' ||
inputs.target == 'dm' && needs.build_dm_model.result == 'success')
}}
runs-on: ubuntu-24.04
permissions:
id-token: write
contents: write
steps:
- uses: actions/checkout@v4
- name: Pull ONNX via LFS
run: git lfs pull -I "${{ needs.resolve.outputs.onnx_path }}"
- name: Install huggingface_hub
run: pip install --upgrade "huggingface_hub>=0.22.0"
- name: Download artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: artifact-name-${{ needs.resolve.outputs.safe_model_name }}
path: artifact_name
- name: Read artifact name
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
id: artifact
run: |
ARTIFACT_NAME=$(cat artifact_name/artifact_name.txt)
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
- name: Download model artifact
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: output
- name: Upload model to HF
if: ${{ inputs.target == 'small' || inputs.target == 'big' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
ARTIFACT_NAME: ${{ steps.artifact.outputs.artifact_name }}
run: |
rm -f output/artifact_name.txt
export PYTHONPATH=$(pwd)
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "$ARTIFACT_NAME" \
--model-dir output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
- name: Download DM artifact
if: ${{ inputs.target == 'dm' }}
uses: actions/download-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output
- name: Generate DM metadata and upload to HF
if: ${{ inputs.target == 'dm' }}
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
export PYTHONPATH=$(pwd)
python3 -c "
import json, hashlib
from pathlib import Path
from datetime import datetime, UTC
dm_dir = Path('dm_output')
manifest = list(dm_dir.glob('*.chunkmanifest'))
assert manifest, 'No chunkmanifest found'
pkl_name = manifest[0].name.removesuffix('.chunkmanifest')
num_chunks = int(manifest[0].read_text().strip())
chunks = []
for i in range(num_chunks):
chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}'
chunks.append({
'file_name': chunk.name,
'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest()
})
digest = hashlib.sha256()
for c in chunks:
with open(dm_dir / c['file_name'], 'rb') as f:
while block := f.read(1024*1024):
digest.update(block)
metadata = {
'bundles': [{
'short_name': 'DMMODEL',
'display_name': '${{ needs.resolve.outputs.model_name }}',
'ref': '${{ needs.resolve.outputs.onnx_ref }}',
'runner': 'tinygrad',
'build_time': datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ'),
'models': [{
'type': 'chunked',
'artifact': {
'file_name': pkl_name,
'download_uri': {'url': '', 'sha256': digest.hexdigest()},
'chunks': chunks
}
}]
}]
}
with open(dm_dir / 'metadata.json', 'w') as f:
json.dump(metadata, f, indent=2)
print('Generated DM metadata.json')
"
python3 release/ci/upload_default_model.py \
--hf-repo "${{ env.HF_REPO }}" \
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
--artifact-name "dm-model-${{ github.run_number }}" \
--model-dir dm_output \
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
--model-name "${{ needs.resolve.outputs.model_name }}" \
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
--run-number "${{ github.run_number }}"
build_dm_model:
needs: resolve
if: ${{ inputs.target == 'dm' }}
runs-on: [self-hosted, tici]
env:
DM_ONNX: openpilot/selfdrive/modeld/models/dmonitoring_model.onnx
DM_PKL: openpilot/selfdrive/modeld/models/dmonitoring_model_tinygrad.pkl
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Pull DM ONNX via LFS
run: git lfs pull -I "${{ env.DM_ONNX }}"
- name: Set environment variables
run: |
source /etc/profile
export UV_PROJECT_ENVIRONMENT=${HOME}/venv
export UV_PYTHON_PREFERENCE=managed
export UV_PYTHON_INSTALL_DIR=${HOME}/uv/python
export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
uv sync --frozen
printenv >> $GITHUB_ENV
- name: Disable powersave
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable
- name: Compile DM model
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
taskset -c 7 env ${TG_FLAGS} python3 \
${{ github.workspace }}/tinygrad_repo/examples/openpilot/compile3.py \
${{ github.workspace }}/${{ env.DM_ONNX }} \
${{ github.workspace }}/${{ env.DM_PKL }}
- name: Chunk DM pkl
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH=${{ github.workspace }}
python3 -c "
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
import os
pkl = '${{ github.workspace }}/${{ env.DM_PKL }}'
size = os.path.getsize(pkl)
targets = get_chunk_targets(pkl, size)
chunk_file(pkl, targets)
print(f'Chunked {pkl} into {len(targets)} chunks')
"
- name: Compile DM warp
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
TG_FLAGS="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
MODEL_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld"
DM_SIZE=$(python3 -c "from openpilot.common.transformations.model import DM_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
for res in $(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')"); do
WARP_PKL="${MODEL_DIR}/models/dm_warp_${res}_tinygrad.pkl"
taskset -c 7 env ${TG_FLAGS} python3 ${MODEL_DIR}/compile_dm_warp.py \
--camera-resolution ${res} \
--warp-to ${DM_SIZE} \
--output ${WARP_PKL}
done
- name: Prepare DM output
run: |
mkdir -p dm_output
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/
cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/
- name: Upload DM artifact
uses: actions/upload-artifact@v4
with:
name: dm-model-${{ github.run_number }}
path: dm_output/
- name: Re-enable powersave
if: always()
run: |
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
@@ -30,7 +30,7 @@ on:
type: boolean
default: true
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: false
type: string
default: 'qcom'
@@ -101,7 +101,7 @@ on:
default: 'qcom'
options:
- qcom
- chestnut
- usbgpu
hf_repo:
description: 'Hugging Face dataset repository'
required: false
@@ -109,7 +109,7 @@ on:
default: 'sunnypilot/sunnypilot_models_v1'
env:
RECOMPILED_DIR: recompiled${{ inputs.recompiled_dir }}
JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'chestnut' && 'chestnut_v' || 'v' }}${{ inputs.json_version }}.json
JSON_FILE: docs/docs/driving_models_${{ inputs.target_hardware == 'usbgpu' && 'usbgpu_v' || 'v' }}${{ inputs.json_version }}.json
jobs:
build_model:
@@ -146,7 +146,7 @@ jobs:
- name: Validate hf_repo and JSON version
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
run: |
if [ ! -f "$JSON_FILE" ]; then
echo "JSON file $JSON_FILE does not exist!"
@@ -155,8 +155,13 @@ jobs:
python3 -c "
import sys
from huggingface_hub import HfApi
HfApi().repo_info(repo_id=sys.argv[1], repo_type='dataset')
print(f'Success: Repo {sys.argv[1]} exists.')
try:
api = HfApi()
api.repo_info(repo_id=sys.argv[1], repo_type='dataset')
print(f'Success: Repo {sys.argv[1]} exists.')
except Exception as e:
print('HF validation failed:', e)
sys.exit(1)
" "${{ inputs.hf_repo }}"
- name: Download artifact name file
@@ -187,7 +192,7 @@ jobs:
- name: Upload to Hugging Face
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
HF_OIDC_RESOURCE: datasets/${{ inputs.hf_repo }}
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
run: |
hf upload ${{ inputs.hf_repo }} \
@@ -1,73 +0,0 @@
name: Download HF model chunks
description: Resolve and download model chunks from HuggingFace in parallel
inputs:
hf_repo:
description: HuggingFace dataset repo
required: true
models:
description: 'JSON array of {hf_path, onnx_hash, canonical} objects'
required: true
dest_dir:
description: Destination directory for downloaded chunks
required: true
runs:
using: composite
steps:
- name: Download model chunks
shell: bash
env:
HF_REPO: ${{ inputs.hf_repo }}
MODELS_JSON: ${{ inputs.models }}
DEST_DIR: ${{ inputs.dest_dir }}
run: |
set -eo pipefail
DOWNLOAD_LIST=$(mktemp)
resolve_chunks() {
local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4"
local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json"
local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS
DEFAULTS=$(curl -fsSL "$JSON_URL")
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)')
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
mkdir -p "$DEST_DIR"
while IFS= read -r CHUNK_NAME; do
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true)
if [ -z "$CHUNK_IDX" ]; then
echo "::error::Failed to parse chunk index from: $CHUNK_NAME"
return 1
fi
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST"
done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name')
echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest"
if [ "$CANONICAL" = "dmonitoring_model_tinygrad.pkl" ]; then
for warp in dm_warp_1928x1208_tinygrad.pkl dm_warp_1344x760_tinygrad.pkl; do
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))")
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST"
done
fi
}
echo "$MODELS_JSON" | jq -c '.[]' | while IFS= read -r model; do
HF_PATH=$(echo "$model" | jq -r '.hf_path')
ONNX_HASH=$(echo "$model" | jq -r '.onnx_hash')
CANONICAL=$(echo "$model" | jq -r '.canonical')
resolve_chunks "$HF_PATH" "$ONNX_HASH" "$CANONICAL" "$DEST_DIR"
done
TOTAL=$(wc -l < "$DOWNLOAD_LIST")
echo "Downloading $TOTAL chunks with 8 parallel connections..."
xargs -P8 -d'\n' -I{} bash -c '
URL="${1%% *}"
DEST="${1#* }"
echo "Downloading $(basename "$DEST")"
curl -fsSL --retry 3 --retry-delay 5 -o "$DEST" "$URL"
' _ {} < "$DOWNLOAD_LIST"
rm -f "$DOWNLOAD_LIST"
@@ -31,7 +31,7 @@ on:
type: string
default: ''
target_hardware:
description: 'Hardware target to compile for (qcom or chestnut)'
description: 'Hardware target to compile for (qcom or usbgpu)'
required: false
type: string
default: 'qcom'
@@ -57,7 +57,7 @@ on:
type: choice
options:
- qcom
- chestnut
- usbgpu
default: 'qcom'
@@ -102,7 +102,7 @@ jobs:
cat $GITHUB_OUTPUT
- run: |
cd ${{ github.workspace }}/openpilot/openpilot
if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then
if [ "${{ inputs.target_hardware }}" != "usbgpu" ]; then
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx"
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx
else
@@ -121,7 +121,7 @@ jobs:
if-no-files-found: error
build_model:
runs-on: [self-hosted, "${{ inputs.target_hardware == 'chestnut' && 'chestnut' || 'tici' }}"]
runs-on: [self-hosted, usbgpu]
needs: get_model
env:
MODEL_NAME: ${{ inputs.custom_name || inputs.upstream_branch }} (${{ needs.get_model.outputs.model_date }})
@@ -185,10 +185,10 @@ jobs:
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then
echo "CHESTNUT build"
export CHESTNUT=1
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1"
if [ "${{ inputs.target_hardware }}" == "usbgpu" ]; then
echo "USBGPU build"
export USBGPU=1
TG_FLAGS="DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV=QCOM FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2"
OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl"
else
echo "QCOM build"
+96 -300
View File
@@ -39,8 +39,6 @@ jobs:
include_big_model: ${{ steps.strategy.outputs.include_big_model }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Extract deploy strategy
id: strategy
run: |
@@ -98,8 +96,6 @@ jobs:
}}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Wait for Tests
uses: ./.github/workflows/wait-for-action # Path to where you place the action
with:
@@ -123,7 +119,6 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: recursive
ref: ${{ env.SOURCE_BRANCH }}
repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }}
@@ -170,7 +165,7 @@ jobs:
scons -j1 cache_dir="$SCONS_CACHE" --minimal \
openpilot/selfdrive/locationd openpilot/sunnypilot/selfdrive/locationd
echo "Building rest of sunnypilot"
SKIP_TINYGRAD_COMPILE=1 /usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal
/usr/bin/time -v scons -j$(nproc) cache_dir="$SCONS_CACHE" --minimal
touch ${BUILD_DIR}/prebuilt
if [[ "${{ runner.debug }}" == "1" ]]; then
ls -la ${BUILD_DIR}
@@ -216,245 +211,91 @@ jobs:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
if: ${{ needs.prepare_strategy.outputs.include_big_model == 'true' }}
concurrency:
group: prepare-chestnut
cancel-in-progress: false
outputs:
onnx_sha256: ${{ steps.resolve.outputs.onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/big
steps:
- name: Resolve ONNX hash and tinygrad ref via API
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || github.ref_name }}
- run: git lfs pull -I "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx"
- name: Check HF defaults and build if needed
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx?ref=${REF}" --jq '.sha')
ONNX_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "ONNX hash: $ONNX_HASH"
[ -n "$ONNX_HASH" ] || { echo "::error::Failed to extract ONNX hash"; exit 1; }
echo "onnx_sha256=$ONNX_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
ACTUAL_ONNX_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" | cut -d' ' -f1)
echo "Repo ONNX hash: $ACTUAL_ONNX_HASH"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
check_hash() {
DEFAULTS=$(curl -fsSL "$JSON_URL" 2>/dev/null) || return 1
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ACTUAL_ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$BUNDLE" ] && [ "$BUNDLE" != "null" ]
}
if check_defaults; then
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
if check_hash; then
echo "HF defaults match repo ONNX"
else
echo "No matching model on HF — triggering build"
gh workflow run build-default-big-model.yaml --ref "${{ github.head_ref || github.ref_name }}"
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=big
sleep 10
echo "Waiting for build to start..."
sleep 120
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
for i in $(seq 1 90); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/90: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "Big model verified on HF"
exit 0
fi
echo "::error::Build succeeded but model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
RUN_ID=$(gh run list --workflow=build-default-big-model.yaml --branch="${{ github.head_ref || github.ref_name }}" --limit=1 --json databaseId --jq '.[0].databaseId')
if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then
echo "::error::Failed to find build-default-big-model run"
exit 1
fi
done
echo "::error::Build run did not complete within 45 minutes"
exit 1
echo "Waiting for run $RUN_ID..."
gh run watch "$RUN_ID"
CONCLUSION=$(gh run view "$RUN_ID" --json conclusion --jq '.conclusion')
if [ "$CONCLUSION" != "success" ]; then
echo "::error::build-default-big-model failed: $CONCLUSION"
exit 1
fi
if ! check_hash; then
echo "::error::HF defaults still don't match after build"
exit 1
fi
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_small_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-small-model
cancel-in-progress: false
outputs:
driving_onnx_sha256: ${{ steps.resolve.outputs.driving_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/small
steps:
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
- name: Download big model chunks
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/driving_supercombo.onnx?ref=${REF}" --jq '.sha')
DRIVING_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "Driving ONNX hash: $DRIVING_HASH"
[ -n "$DRIVING_HASH" ] || { echo "::error::Failed to extract driving ONNX hash"; exit 1; }
echo "driving_onnx_sha256=$DRIVING_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
ACTUAL_ONNX_HASH=$(sha256sum "openpilot/selfdrive/modeld/models/big_driving_supercombo.onnx" | cut -d' ' -f1)
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
DEFAULTS=$(curl -fsSL "$JSON_URL")
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ACTUAL_ONNX_HASH" '.bundles[] | select(.onnx_sha256 == $hash)')
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DRIVING=$(echo "$DEFAULTS" | jq --arg hash "$DRIVING_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DRIVING" ] && [ "$DRIVING" != "null" ] || return 1
}
mkdir -p big_model_chunks
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
if check_defaults; then
echo "HF defaults match repo ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=small
sleep 10
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
for i in $(seq 1 60); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/60: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "Small model verified on HF"
exit 0
fi
echo "::error::Build succeeded but model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
fi
CANONICAL="big_driving_tinygrad.pkl"
echo "$ARTIFACT" | jq -r '.chunks[].file_name' | while read CHUNK_NAME; do
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+')
CANONICAL_CHUNK="${CANONICAL}.chunk${CHUNK_IDX}"
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
echo "Downloading $CHUNK_NAME -> $CANONICAL_CHUNK"
curl -fsSL -o "big_model_chunks/${CANONICAL_CHUNK}" "$ENCODED_URL"
done
echo "::error::Small model build did not complete within 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
echo "$NUM_CHUNKS" > "big_model_chunks/${CANONICAL}.chunkmanifest"
- name: Cancel run on failure
if: failure()
run: gh run cancel ${{ github.run_id }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare_dm_model:
needs: [ prepare_strategy ]
runs-on: ubuntu-24.04
concurrency:
group: prepare-dm-model
cancel-in-progress: false
outputs:
dm_onnx_sha256: ${{ steps.resolve.outputs.dm_onnx_sha256 }}
env:
GH_REPO: ${{ github.repository }}
HF_REPO: sunnypilot/sunnypilot_models_v1
HF_DEFAULTS_PATH: models/defaults/dm
steps:
- name: Resolve ONNX hash and tinygrad ref via API
id: resolve
run: |
REF="${{ github.head_ref || github.ref_name }}"
BLOB_SHA=$(gh api "repos/${GH_REPO}/contents/openpilot/selfdrive/modeld/models/dmonitoring_model.onnx?ref=${REF}" --jq '.sha')
DM_HASH=$(gh api "repos/${GH_REPO}/git/blobs/${BLOB_SHA}" --jq '.content' | base64 -d | grep '^oid sha256:' | cut -d: -f2)
echo "DM ONNX hash: $DM_HASH"
[ -n "$DM_HASH" ] || { echo "::error::Failed to extract DM ONNX hash"; exit 1; }
echo "dm_onnx_sha256=$DM_HASH" >> $GITHUB_OUTPUT
TINYGRAD_REF=$(gh api "repos/${GH_REPO}/contents/tinygrad_repo?ref=${REF}" --jq '.sha')
echo "tinygrad ref: $TINYGRAD_REF"
JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_DEFAULTS_PATH}/default_models.json"
check_defaults() {
DEFAULTS=$(curl -fsSL "${JSON_URL}?t=$(date +%s)" 2>/dev/null) || return 1
TINYGRAD_MATCH=$(echo "$DEFAULTS" | jq -r --arg ref "$TINYGRAD_REF" '.tinygrad_ref == $ref' 2>/dev/null)
[ "$TINYGRAD_MATCH" = "true" ] || return 1
DM=$(echo "$DEFAULTS" | jq --arg hash "$DM_HASH" '.bundles[] | select(.onnx_sha256 == $hash)' 2>/dev/null)
[ -n "$DM" ] && [ "$DM" != "null" ] || return 1
}
if check_defaults; then
echo "HF defaults match DM ONNX hash and tinygrad ref"
exit 0
fi
echo "No matching DM model on HF — dispatching build"
gh workflow run build-default-models.yaml --ref "$REF" -f target=dm
sleep 10
BUILD_RUN_ID=$(gh run list --workflow build-default-models.yaml --branch "$REF" --limit 1 --json databaseId --jq '.[0].databaseId')
echo "Dispatched build run: $BUILD_RUN_ID"
echo "Waiting for build run to complete..."
for i in $(seq 1 60); do
sleep 30
STATUS=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.status')
CONCLUSION=$(gh api "repos/${GH_REPO}/actions/runs/${BUILD_RUN_ID}" --jq '.conclusion')
echo "Poll $i/60: status=$STATUS conclusion=$CONCLUSION"
if [ "$STATUS" = "completed" ]; then
if [ "$CONCLUSION" = "success" ]; then
echo "Build run succeeded, verifying HF..."
sleep 10
if check_defaults; then
echo "DM model verified on HF"
exit 0
fi
echo "::error::Build succeeded but DM model not found on HF"
exit 1
else
echo "::error::Build run failed with conclusion=$CONCLUSION"
exit 1
fi
fi
done
echo "::error::DM model build did not complete within 30 minutes"
exit 1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload big model chunks
uses: actions/upload-artifact@v4
with:
name: big-model-chunks
path: big_model_chunks/
compression-level: 0
- name: Cancel run on failure
if: failure()
@@ -464,24 +305,23 @@ jobs:
publish:
concurrency:
# We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name.
# This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other.
# Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time.
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) &&
(needs.prepare_strategy.outputs.include_big_model != 'true' || needs.prepare_chestnut.result == 'success')
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
needs: [ build, prepare_strategy, prepare_chestnut ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
uses: actions/download-artifact@v4
@@ -493,16 +333,23 @@ jobs:
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
- name: Prepare chestnut output
if: ${{ needs.prepare_chestnut.result == 'success' }}
run: |
mkdir -p "${{ github.workspace }}/chestnut_output"
tar xzf prebuilt.tar.gz -C "${{ github.workspace }}/chestnut_output"
- name: Download big model chunks
if: ${{ needs.prepare_chestnut.result == 'success' }}
uses: actions/download-artifact@v4
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"}
]
name: big-model-chunks
path: big_model_chunks
- name: Inject big model into chestnut
if: ${{ needs.prepare_chestnut.result == 'success' }}
run: |
cp big_model_chunks/* "${{ github.workspace }}/chestnut_output/openpilot/selfdrive/modeld/models/"
- name: Configure Git
run: |
@@ -524,6 +371,22 @@ jobs:
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
- name: Publish chestnut branch
if: ${{ needs.prepare_chestnut.result == 'success' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
CHESTNUT_DIR="${{ github.workspace }}/chestnut_output"
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"$CHESTNUT_DIR" \
"$CHESTNUT_BRANCH" \
"${{ needs.prepare_strategy.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
- name: Tag ${{ needs.prepare_strategy.outputs.environment }}
if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }}
run: |
@@ -531,77 +394,12 @@ jobs:
git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.prepare_strategy.outputs.build }}."
git push -f origin ${TAG}
publish_chestnut:
concurrency:
group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}-chestnut
cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
if: ${{
always() && !cancelled() &&
needs.build.result == 'success' &&
needs.prepare_strategy.result == 'success' &&
needs.prepare_small_model.result == 'success' &&
needs.prepare_dm_model.result == 'success' &&
needs.prepare_chestnut.result == 'success' &&
(!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
}}
needs: [ build, prepare_strategy, prepare_chestnut, prepare_small_model, prepare_dm_model ]
runs-on: ubuntu-24.04
environment: ${{ needs.prepare_strategy.outputs.environment }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download prebuilt artifact
uses: actions/download-artifact@v4
with:
name: prebuilt
- name: Untar prebuilt
run: |
mkdir -p ${{ env.OUTPUT_DIR }}
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
- name: Download model chunks from HF
uses: ./.github/workflows/download-hf-model-chunks
with:
hf_repo: sunnypilot/sunnypilot_models_v1
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
models: |
[
{"hf_path": "models/defaults/small", "onnx_hash": "${{ needs.prepare_small_model.outputs.driving_onnx_sha256 }}", "canonical": "driving_tinygrad.pkl"},
{"hf_path": "models/defaults/dm", "onnx_hash": "${{ needs.prepare_dm_model.outputs.dm_onnx_sha256 }}", "canonical": "dmonitoring_model_tinygrad.pkl"},
{"hf_path": "models/defaults/big", "onnx_hash": "${{ needs.prepare_chestnut.outputs.onnx_sha256 }}", "canonical": "big_driving_tinygrad.pkl"}
]
- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git config --global user.name "github-actions[bot]"
- name: Publish chestnut branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CHESTNUT_BRANCH="${{ needs.prepare_strategy.outputs.new_branch }}-chestnut"
${{ env.CI_DIR }}/publish.sh \
"${{ github.workspace }}" \
"${{ env.OUTPUT_DIR }}" \
"$CHESTNUT_BRANCH" \
"${{ needs.prepare_strategy.outputs.version }}" \
"https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
"${{ needs.prepare_strategy.outputs.extra_version_identifier }}"
notify:
needs:
- prepare_strategy
- build
- publish
- publish_chestnut
- prepare_chestnut
- prepare_small_model
- prepare_dm_model
runs-on: ubuntu-24.04
if: ${{ (always() && !cancelled() && !failure())
&& needs.publish.result == 'success'
@@ -609,8 +407,6 @@ jobs:
&& (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare notification message
id: message
-124
View File
@@ -1,124 +0,0 @@
# Ford C1 feedback experiment
The restored original v1 can leave a steering error while C0 and C1 still have
room. Its command law does not directly correct measured steering error. This
experiment keeps that mapping and adds one accumulated C1 correction:
```text
error = selected_limited_desired_curvature - measured_curvature
correction += error * speed * elapsed_measurement_time
C1_target = original_model_C1 + correction
```
Curvature (1/m) multiplied by traveled distance (m) gives heading mismatch in
radians. Applying that mismatch to C1 at **1:1 is an explicit feedback-strength
choice**. Dimensional consistency does not prove that every PSCM responds
correctly to that strength. There is no fitted PSCM response model or new
tunable multiplier.
For example, at 20 m/s, a constant curvature shortfall of 0.001/m adds 0.02 rad
to C1 over one second when the output can accept it. When measured curvature
matches the request, the correction holds. If the vehicle turns more than
requested, the correction moves in the unwind direction. Changing the model
request still changes the base immediately, subject to the existing slew.
## Preserved mapping and limits
- C0 is the current model path's lateral offset at 7 m of arc distance, holding
the available endpoint for shorter paths; its limits remain ±5.11 m and 4 m/s.
- Base C1 is `max(7 m, speed × 1 s) × selected_limited_desired_curvature`, clipped
to ±0.5 rad. Final C1 uses the same ±0.5 rad and 0.5 rad/s limits as v1.
- C2 and C3 are zero. Sign conversion, Float32/CAN rounding, upstream curvature
limiting and the 100 Hz sender retain their existing behavior.
The core holds three values: unquantized C0, unquantized C1 and the correction.
Zero error from a reset leaves the correction at zero and preserves the old
command arithmetic exactly. There is no separate percentage or distance cap
on the correction.
## Feedback measurement, timing and limits
The measurement is `controlsd.curvature`, computed from measured steering
angle with the existing live vehicle parameters. It matches the curvature
used for the desired-versus-actual steering comparison. It is not an independent
measurement of tire slip or the vehicle's actual ground path. CAN yaw remains
an input-health gate and does not drive this feedback.
The adapter integrates only elapsed time between fresh `carState` publications.
The first publication after reset integrates zero time. Duplicate timestamps
integrate zero; a fresh timestamp accounts for the elapsed measurement interval.
Output slew continues on valid control cycles. Existing service-age, speed,
model-geometry and clock-order gates remain, with the same finite/range check
also applied to measured curvature. Disengagement or invalid input clears all
three core states.
The correction cannot accumulate farther into an unavailable C1 amplitude or
slew request. Increments that move back toward the available output remain
allowed. Moving the base request does not itself rewrite the correction.
Fresh PSCM status means a valid message whose original CAN receipt timestamp
is within the existing 5 to +150 ms age allowance. Reached-limit status (2)
prevents extra accumulation in the measured turn direction. An old correction
opposing that direction can return to zero; it cannot be trapped below the
base request by the limit flag. Unwind and base model changes remain available.
Close-to-limit status (1) does not block feedback. Missing or stale status
does not gate it; local amplitude and slew anti-windup still apply.
Driver steering-pressed, torque above the existing 1 Nm allowance, nonfinite
torque, or fresh driver-limit status (3) clears the correction. Fresh denied
or inactive PSCM status also clears it. The base model request continues
through existing engagement and driver arbitration; clearing the correction
does not bypass the final output slew.
## Offline evidence and reproduction
`ford_c1_feedback_validation.json` records the source hashes and completed
checks. Tests exercise build, hold, unwind, saturation, limit flags, immediate
driver input, stale and repeated measurements, invalid inputs and both signs.
Integration tests execute actual controlsd selection and limiting, Float32
publication, CarControlSP conversion and the Ford CarController CAN builder.
Randomized runs check feedback invariants separately from zero-error
compatibility with the original independent scalar oracle.
The combined suite passes **511 tests and 9,146 subtests**. Its 178 skips are
in inherited safety base classes or unsupported safety-test variants. Ruff,
the controller's Ty check and settings compilation pass. Feedback stress,
zero-error stress and the b8 replay total **578,569 Float32/CAN round trips**;
the integration test separately verifies 1,010 transmitted packet constructions,
including every counter and checksum. No packets are sent to hardware.
The b8 replay retains recorded desired/measured curvature, model publications,
driver input and PSCM flags. It compares candidate commands with the restored
v1 at `a7d70e2b0890184636827351e4789d866f2a7c97`. All 160,431 reconstructed
activation decisions and C0 commands match. C1 changes on 58,106 cycles.
At 4:12.493, for example, reconstructed host C1 changes from 0.1625 to
0.2035 rad; at 3:56.250 it changes from 0.1280 to 0.1080 rad. These are
changes to commands on frozen measurements, not predicted wheel angles.
Controls publication time proxies the unlogged computation clock, and the
full SubMaster health state cannot be reconstructed. This route uses the
consumed model publication as its reference and has no maneuver-plan messages.
Replay cannot show whether this feedback fixes weak turns, hanging turns or
oscillation. A new drive is needed to measure those outcomes.
Use the branch's native dependencies and pinned opendbc revision
`c21a9013700734dd20b09e05aa68329ad8cc20f9`:
```sh
export PYTHONDONTWRITEBYTECODE=1
export PYTHONPATH=.:opendbc_repo
python -m pytest -q -p no:cacheprovider openpilot/selfdrive/controls/tests/test_ford_*.py tools/ford_pscm_lab openpilot/selfdrive/car/tests/test_ford_pscm_status.py openpilot/sunnypilot/sunnylink/tests openpilot/common/tests/test_params.py opendbc_repo/opendbc/car/ford/tests/test_ford.py opendbc_repo/opendbc/safety/tests/test_ford.py
python openpilot/sunnypilot/sunnylink/tools/compile_settings_ui.py --check
python -m tools.ford_pscm_lab.feedback_replay stress --cycles 200000 --output .cache/ford_c1_feedback/feedback_stress.json
python -m tools.ford_pscm_lab.stress_model_action --cycles 200000 --seed 20260907 --opendbc-revision c21a9013700734dd20b09e05aa68329ad8cc20f9 --output .cache/ford_c1_feedback/zero_error_stress.json
python -m tools.ford_pscm_lab.feedback_replay route .cache/ford_routeb8 --output .cache/ford_c1_feedback/routeb8
```
The last command requires the existing full-rlog b8 extract (`route.npz`,
`model_paths.npz`, `metadata.json`), identified by hashes in the validation
record. The historical route90/95 replay deliberately sets measured curvature
equal to requested curvature to check zero-error compatibility; it does not
exercise recorded steering feedback.
Enable using the [existing Sunnylink toggle](ford_model_action_drive_test.md).
The diagnostic identity is `model-action-c1-feedback-v1`.
-258
View File
@@ -1,258 +0,0 @@
{
"created_at_utc": "2026-09-09T14:45:33.853345+00:00",
"baseline_commit": "a7d70e2b0890184636827351e4789d866f2a7c97",
"deployment_target": {
"repository": "sunnypilot/sunnypilot",
"branch": "hiimisaac-dev"
},
"scope": "C1 measured-curvature feedback on restored original v1. Offline software validation only; no predicted or measured physical improvement.",
"calibration_approved": false,
"toggle": {
"key": "FordModelActionController",
"default_enabled": false,
"activation": "Existing startup selection after offroad-to-onroad cycle"
},
"feedback_law": "correction += (desired_curvature - measured_curvature) * speed * elapsed_measurement_time, subject to output and PSCM anti-windup",
"feedback_strength": "Explicit 1:1 heading-error-to-C1 choice; no fitted PSCM plant or new tunable multiplier",
"preserved": [
"C0 mapping and limits",
"C2=C3=0",
"C1 final amplitude and slew limits",
"100 Hz sender",
"upstream selection and limiting"
],
"panda_safety_changed": false,
"opendbc_submodule_changed": false,
"opendbc_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9",
"controller_size": {
"total_lines": 181,
"code_lines_excluding_blanks_comments_docstrings": 123,
"core_persistent_values": 3,
"adapter_timestamps": 3
},
"tests": {
"combined_suite": "511 passed, 178 skipped, 9146 subtests passed in 5.14s",
"suite_log_sha256": "001ef6633b22513317593dd8debc160a0ca8aaf78ea53418c5f7a50c370cc818",
"ruff_changed_python": "pass",
"ty_controller": "pass",
"settings_compiler_check": "pass",
"safety_skip_reasons": [
"SKIPPED [145] ../../../../dev/sunnypilot/.venv/lib/python3.12/site-packages/_pytest/unittest.py:523: Skipped",
"SKIPPED [9] opendbc_repo/opendbc/safety/tests/common.py:64: Safety mode implements no _user_regen_msg",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:51: Skipping test because MADS button is not supported",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:254: Skipping test because MADS button is not supported",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:67: Skipping test because _acc_state_msg is not implemented for this car",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:165: Skipping test because MADS button is not supported",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:165: Skipping test because ACC main is not supported",
"SKIPPED [3] opendbc_repo/opendbc/safety/tests/mads_common.py:411: MADS button not supported",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:378: CAN FD only",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:361: CAN FD only",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:351: CAN FD only",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:327: CAN FD only",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:341: CAN FD only",
"SKIPPED [1] opendbc_repo/opendbc/safety/tests/test_ford.py:320: CAN FD only"
],
"safety_native_build": "Pinned safety C source is compiled locally by libsafety_py before testing.",
"controlsd_to_can_feedback_integration_frames": 1010,
"integration_checks": "Both signs: build, hold, unwind, rebuild, immediate driver override; actual 100 Hz sender, counter, checksum, fields and publication. Separate integration tests validate PSCM service forwarding.",
"regression_test_evidence": [
"Nonzero-error integration failed with zero correction before implementing feedback.",
"Both sign tests failed when a reached limit trapped an old opposing correction; they pass after allowing return to zero."
]
},
"route_b8": {
"baseline_revision": "a7d70e2b0890184636827351e4789d866f2a7c97",
"baseline_source_sha256": "8f3bc5d68e0051776f614a2ccffae84a88f7898dc95bdc12c23dcfe10dfe676a",
"cycles": 160431,
"active_cycles": 68217,
"validity_and_c0_match_original_v1_exactly": true,
"status_counts": {
"inactive": 92214,
"active": 68217
},
"feedback_enabled_seconds": 598.256888772994,
"pscm_limit_2_seconds": 12.139079590997426,
"c1_changed_cycles": 58106,
"max_abs_c1_change_rad": 0.29800000000000004,
"max_abs_correction_rad": 0.29816844327770786,
"can_round_trips": 160431,
"timing_limit": "Controls publication time proxies the computation clock; full SubMaster checks are unavailable.",
"reference_limit": "Uses exact consumed model publication as reference; the b8 route has no maneuver-plan messages.",
"example_points": [
{
"time_s": 130.9368894940053,
"old_c0_c1": [
-0.7400000000000002,
-0.18700000000000006
],
"candidate_c0_c1": [
-0.7400000000000002,
-0.22899999999999998
],
"correction_rad": -0.042171663052515254,
"feedback_enabled": true,
"pscm_limited": false
},
{
"time_s": 235.3960996990063,
"old_c0_c1": [
-2.04,
-0.40449999999999997
],
"candidate_c0_c1": [
-2.04,
-0.4145
],
"correction_rad": -0.00989648519895422,
"feedback_enabled": true,
"pscm_limited": true
},
{
"time_s": 236.25034470800165,
"old_c0_c1": [
-1.46,
-0.128
],
"candidate_c0_c1": [
-1.46,
-0.10799999999999998
],
"correction_rad": 0.01990758350705991,
"feedback_enabled": true,
"pscm_limited": false
},
{
"time_s": 252.49320156300382,
"old_c0_c1": [
-0.6699999999999999,
-0.16249999999999998
],
"candidate_c0_c1": [
-0.6699999999999999,
-0.20350000000000001
],
"correction_rad": -0.040907632902654506,
"feedback_enabled": true,
"pscm_limited": false
},
{
"time_s": 674.430371745002,
"old_c0_c1": [
0.4299999999999997,
0.128
],
"candidate_c0_c1": [
0.4299999999999997,
0.1345
],
"correction_rad": 0.00639271291315417,
"feedback_enabled": true,
"pscm_limited": false
},
{
"time_s": 1534.5190040400048,
"old_c0_c1": [
2.62,
0.5
],
"candidate_c0_c1": [
2.62,
0.5
],
"correction_rad": 0.0,
"feedback_enabled": true,
"pscm_limited": true
},
{
"time_s": 1562.5074677500015,
"old_c0_c1": [
-0.1200000000000001,
-0.051000000000000045
],
"candidate_c0_c1": [
-0.1200000000000001,
-0.046499999999999986
],
"correction_rad": 0.004453988923883501,
"feedback_enabled": true,
"pscm_limited": false
}
]
},
"route_input_sha256": {
"route.npz": "6f5dd369b70eaed4b95b28c8b25c9f2e9b830fa07a334881a185505481667c8b",
"model_paths.npz": "939af6cf7e74251d8842581cc078d26d9fbfd22a0d7817cb0e368697d419b615",
"metadata.json": "73b439132d1de37ec187b544c04d2b05c80965065515a4b7dec29ba57ae37e7c"
},
"feedback_stress": {
"cycles": 200000,
"mirrored_updates": 200000,
"can_round_trips": 200000,
"checks": "Mirror symmetry, reset/override, amplitude, slew, correction bounds, integration direction/size, PSCM anti-windup, CAN fields.",
"scope": "Numerical software invariants only; no model of vehicle motion.",
"calibration_approved": false,
"controller_sha256": "4499defbb7fc5ddf5029ca42c549f0935b0758b08818c5bf0490fb52221f9a34"
},
"zero_error_stress": {
"seed": 20260907,
"random_cycles": 200000,
"mirrored_core_updates": 200000,
"invalid_or_inactive_resets": 3537,
"field_boundary_cases": 18138,
"float32_can_round_trips": 218138,
"analytic_targets_scalar_slew_and_mirror_checks_pass": true,
"direct_raw_float32_packing_matches_host_output": true,
"max_continuous_step_c0_c1": [
0.40000000000000147,
0.05000000000000002
],
"calibration_approved": false,
"scope": "Zero-error numerical construction: measured equals requested curvature. No PSCM response claims.",
"opendbc_import_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9"
},
"total_lab_float32_can_round_trips": 578569,
"artifact_sha256": {
".cache/ford_c1_feedback/routeb8/report.json": "648887eebb76260a60f7c0f0d9aaac83d0f1c06b443e28bc6fdf296bad4526c0",
".cache/ford_c1_feedback/routeb8/commands.npz": "1aef98365572b3fb3cb8ba2a93cf30041ff3be133718d469145b710f2c94dc32",
".cache/ford_c1_feedback/feedback_stress.json": "abf4e7bccc1e460008cc7450fcd92e9b2a6108bd71e53a01bdc24e31e5b5ad32",
".cache/ford_c1_feedback/zero_error_stress.json": "2a3f284e10e5054205a788cce59bcf57bd837e13afff327457244141ba5522f0",
".cache/ford_c1_feedback/safety_skip_reasons.txt": "5384c82b07b7cc20c6b22b8e94246cb104d53f8866af02b28fda7d4138cf377f"
},
"native_params": {
"library_sha256": "270bf43241cf7c02cc432cf78ec9411a62d7653ca445695efe785ae82241aa09",
"sources_match_original_rebuild_record": true,
"provenance": "Same locally rebuilt native library and source hashes recorded in ford_model_action_drive_test_validation.json; verified for this run."
},
"test_environment": {
"python": "/Users/ibpersonal/dev/sunnypilot/.venv/bin/python",
"PYTHONPATH": ".:opendbc_repo:.cache/ford_v6/test_deps",
"LOG_ROOT": "/private/tmp/ford-feedback-logs",
"PARAMS_ROOT": "/private/tmp/ford-feedback-params",
"PYTHONDONTWRITEBYTECODE": "1"
},
"source_sha256": {
"docs/ford_c1_feedback.md": "c1bc7f24c5ebe28679b4a04d09085d7b937926e63a38d43a3abfac93dfcfa0f9",
"docs/ford_model_action_candidate.md": "20cd8d10008cd796cc8719f5795ee80f50d8133d6e7684fb057a78fb05323fbe",
"docs/ford_model_action_drive_test.md": "7860ae26a61682aff86743ba302eb23c8f271d5700a2e616da1b6b38d438b57d",
"opendbc_repo/opendbc/car/vehicle_model.py": "ddc2a93d9c2b2ef6c9a913a5aef4c51e2bc387db1f7640473657e5ade4e50fac",
"openpilot/selfdrive/controls/controlsd.py": "2b7e246f00bccce3a2bb9f6f44009ca77690cadb8527cd2bdfe855e9ad72ad1e",
"openpilot/selfdrive/controls/lib/drive_helpers.py": "916bcd83c2a909a89795da58c7c43d7b168c9b82e1a6d281484bae45c667c01e",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "4499defbb7fc5ddf5029ca42c549f0935b0758b08818c5bf0490fb52221f9a34",
"openpilot/selfdrive/controls/lib/ford_path.py": "383538fc7cdae3bc28dffb71fe12ac5f3f9866ffbe6adfb7457f3593e9fc903a",
"openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py": "84113b1b7800c868117af0034278f53a1a6153c7bb5fadc1ea45958e62c4f0d0",
"openpilot/selfdrive/controls/tests/test_ford_model_action.py": "cbe1b2aa1961deba3a42e1d82f5f75ae0c3d7a219428dea5f50cb70e1b27fd11",
"openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py": "c7f5ffd650e804e6e02fa12d435e0867b56b13a49c3d9fa511993188d5cb625a",
"openpilot/selfdrive/controls/tests/test_ford_model_action_feedback.py": "f7a956c082a246d9506e21adbf348cbdc7f94d5342d832841058c71f7e264eeb",
"openpilot/sunnypilot/selfdrive/controls/controlsd_ext.py": "7a13dc5ce49b40e27e05e62cdb9ef1bb764de8ed8167f7e982d54a4dffe97ed4",
"openpilot/sunnypilot/sunnylink/settings_ui.json": "7d38f315a7c5ce6d46d01a06f7eaddd4933f85639e5325ff71fdce22866ef401",
"openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml": "410e306958ece12e49fc114707741c57a2dd927c6ba3410e160834e52a759ea9",
"tools/ford_pscm_lab/feedback_replay.py": "ca552217953f3cce35da0b1666252fd43b6f8ab6c067e5c9102da8ea8d97c2f3",
"tools/ford_pscm_lab/model_action_replay.py": "af97c665f342c66b1be2502e188c63e6f3ee106d0a0d5e80997bc3040373ff9f",
"tools/ford_pscm_lab/stress_model_action.py": "0b25188edf2b248ebe741173ce02ce75bd59f1f39fd5bd909d41a3dca2294aa8"
},
"limitations": [
"Frozen route replay changes commands only; it cannot establish tracking, unwind response or closed-loop stability.",
"Measured curvature uses the existing steering-angle vehicle model; it is not an independent ground-path measurement.",
"No full device build, device boot, installation or road validation was performed."
]
}
-155
View File
@@ -1,155 +0,0 @@
# Offline Ford selected-action candidate
This document and `ford_model_action_validation.json` record the offline
stage committed as `7ca3c6e3b`. The candidate is now available behind a
separate default-off Sunnylink toggle; see
[drive-test setup and validation](ford_model_action_drive_test.md).
The counts, source hashes and selector status below describe that earlier
stage. The current experiment adds [measured-curvature C1 feedback](ford_c1_feedback.md)
to this original mapping; the historical no-feedback description below is
not the current controller specification.
The decision is `C0 = current model y(7 m)`,
`C1 = max(7 m, speed × 1 s) × selected upstream-limited desiredCurvature`,
with C2=C3=0. The 7 m station and one-second scale are engineering choices,
not identified PSCM gains. `calibration_approved=false`.
`openpilot/selfdrive/controls/lib/ford_model_action.py` contains the core
and a separate adapter compatible with the existing controlsd call.
At that stage, the production selector, v8 implementation, settings, opendbc
submodule and Panda safety remained unchanged. Tests injected the adapter
offline; there was no production setting. No hardware or CAN transmission
occurs in the lab tools.
## Construction and integration
Only the unquantized C0 and C1 slew positions persist in the core.
Each field is clipped independently (±5.11 m / ±0.5 rad), slewed independently
(4 m/s / 0.5 rad/s), then packed using the existing Float32/sign-negation
rounding contract (0.01 m / 0.0005 rad). Heading overflow is not transferred
to C0. No yaw integral, blend, additional curvature contribution, reference
filter, turn modes, or 10 m C1 cap is introduced.
The selected standalone implementation from worktree 3548 is the provenance
for this law. Its two-state packer has been moved into the library core so
the controller does not depend on experimental lab code. Invalid numeric
types, overflowing arc geometry and malformed paths reset the core instead
of throwing or retaining a command.
Arc stations use cumulative model x/y distance, not forward x. As in the
reviewed standalone core, a path ending before 7 m holds its available
endpoint instead of extrapolating. This matters: route95 contains 44 active
cycles with 5.456.94 m of path at 2.783.46 m/s. A tested strict 7 m
coverage gate would have introduced disengagements and was removed. There
is no speed-dependent C0 horizon beyond this existing endpoint behavior.
The adapter retains the existing input age allowance (5 to +150 ms),
speed domain (0.355 m/s), yaw sanity bound (±3 rad/s), selected curvature
sanity bound (±1/m), and control interval (2100 ms). It rejects backward
model/measurement timestamps and invalid services. Repeated timestamps may
continue slew, but geometry is validated again on each tick. Disengagement,
invalid inputs and timing faults clear all command and adapter timing state.
The first valid tick after reset uses 10 ms, as v8 does.
controlsd still owns reference selection, upstream curvature limiting,
service health and engagement. Tests execute its actual source-selection
and limiter code, its Ford call, Float32 publication in ControlsExt, conversion
to CarControlSP, and the pinned Ford CarController's in-memory CAN builder.
Both model-action and maneuver-planner selection are covered, including
disabling latActive after invalid output. Only the test chooses the adapter.
Yaw is not an input to the control law. The adapter checks it solely for the
inherited invalid-input policy. Driver override and optional PSCM status
do not modify the candidate base; existing engagement and downstream driver
arbitration remain responsible for authorization, as with v8's base request.
## Offline evidence
The checked-in `ford_model_action_validation.json` records the completed
checks and source hashes. Full arrays and detailed reports are generated
locally under `.cache/ford_model_action/`; original route files are read-only.
Completed validation: **264 Ford tests and 150 subtests pass**, including
120 new core/adapter/replay-validator cases. The candidate module has 100%
statement and branch coverage (78 statements, 24 branches). Ruff and Ty pass.
The 200,000-cycle numerical stress test also checks 200,000 mirrored core
updates and 18,138 field-boundary cases. Across route and stress runs,
485,238 Float32/CAN round trips pass. Eight deliberately injected faults
(heading gain/cap, erased C0, wrong C0 slew, retained invalid state, stale
model acceptance, model clock rollback and reversed C0 sign) are all caught
by the tests. Mutation runs replace code only inside isolated Python
processes; production source files are never modified by those probes.
Independent Standards and Spec reviews reported zero findings. The full
suite's Params setting test uses an existing local native library from
worktree 3548 after checking relevant source files are byte-identical;
its hash and provenance are in the manifest. That library is an ignored
test dependency, not part of this change. This is the full relevant Ford
suite, not the hardware-dependent test suite for every openpilot subsystem.
The replay has two separate passes:
* Core compatibility uses the archived eligibility mask and requires exact
equality with the independently implemented `action_heading` commands.
* Adapter reconstruction derives eligibility from recorded service streams
independently of the archived output mask. It retains original timestamps,
gaps and consumed model frames. Controls publication time proxies the
unlogged computation clock, and complete SubMaster health is unavailable.
All 54,738 route95 and 78,812 route90 core cycles match exactly, including
37,614 and 73,055 active cycles. The adapter preserves those active counts.
Its 59 / 19 changed commands arise solely from the fresh 10 ms engagement
tick instead of the archived harness's preceding publication interval;
the replay checks that attribution on every cycle. Maximum differences are
0.01 m / 0.001 rad (95) and 0.02 m / 0.002 rad (90).
Every core and adapter replay output is round-tripped through Float32 and
the real CAN packer/parser, including zero C2/C3, signs, mode and counter.
Continuous field slew and quantization allowance are checked separately
from immediate invalid-command resets. The original driver-clean cohorts,
speed strata and command RMS are reproduced without redoing the encoder search.
The numerical stress harness uses analytic rotated paths, scalar slew
arithmetic, mirrored requests, irregular intervals and invalid-input resets.
It also sweeps every representable host field value and the Float32 values
immediately below, at and above every half-quantum boundary. Direct CAN
packing of the continuous state must agree with the host's quantized output.
The unit tests cover releases, reversals, clipping, service freshness,
clock resets, malformed inputs, endpoint fallback and actual integration.
## Limits of the result
On turns at ≥15 m/s, candidate C0 RMS is 79%/81% below v8 on routes95/90,
while C1 is 33%/41% higher. Those are command changes, not evidence of
equivalent steering authority. The PSCM's independent C0/C1 response remains
unknown. Replay cannot establish physical model following, strong turns,
centering, overshoot, oscillation or closed-loop stability.
The release probe is intentionally explicit: a model bend can increase
while selected curvature decreases. At 20 m/s, one synthetic probe changes
C0/C1 from 0.24 m / 0.10 rad to 0.49 m / 0.08 rad. Zero selected curvature
sets the C1 target to zero but does not erase a nonzero current model C0.
Removing a yaw-integral tail does not prove that physical overshoot is solved.
No additional release policy or unsupported plant model is added to hide
that uncertainty.
## Reproduce
From this worktree, use the logged construction dependency explicitly:
```sh
export PYTHONDONTWRITEBYTECODE=1
export PYTHONPATH=.:/Users/ibpersonal/.codex/worktrees/b926/sunnypilot/opendbc_repo
PY=/Users/ibpersonal/dev/sunnypilot/.venv/bin/python
EVIDENCE=/Users/ibpersonal/.codex/worktrees/3548/sunnypilot/analysis/controller_search_20260904
$PY -m pytest -q -p no:cacheprovider openpilot/selfdrive/controls/tests/test_ford_*.py tools/ford_pscm_lab openpilot/selfdrive/car/tests/test_ford_pscm_status.py
$PY -m tools.ford_pscm_lab.model_action_replay "$EVIDENCE/route95" --output .cache/ford_model_action/route95
$PY -m tools.ford_pscm_lab.model_action_replay "$EVIDENCE/route90" --output .cache/ford_model_action/route90
$PY -m tools.ford_pscm_lab.stress_model_action --cycles 200000 --seed 20260907 --output .cache/ford_model_action/stress.json
```
The route replay refuses an opendbc revision other than
`72a775d35e54c21ff5c5798acef22016eedcc0a7`. Stress defaults to this pin and
also accepts an explicitly required commit with `--opendbc-revision` for
deployment checks. A mismatch still fails. This historical pin reproduces
logged construction; it does not change the merge's submodule pointer.
-53
View File
@@ -1,53 +0,0 @@
# Ford selected-action drive-test branch
The current experiment adds [measured-curvature C1 feedback](ford_c1_feedback.md)
to the restored original v1 mapping. It is selectable on the **Ford CAN FD
F-150 Lightning** through the existing persistent, default-off Sunnylink
toggle. Offline checks establish software behavior; physical tracking,
turn-exit behavior and closed-loop stability remain unvalidated.
## Select and restore
1. Install branch `hiimisaac-dev` from `sunnypilot/sunnypilot` using the device's
normal branch-switch process and allow its build to finish.
2. While offroad, open Sunnylink device settings → Vehicle → Ford and enable
**Selected-Action Path Tracking (Experimental)** (`FordModelActionController`).
3. Complete a real offroad-to-onroad cycle. Selection occurs when `controlsd`
starts; a stored toggle change or disengagement alone cannot swap an active
controller. Initial physical evaluation remains controlled testing.
The startup event `Ford path controller selected` should report
`FordModelActionController`. Periodic `Ford C2-free path tracking` events
identify **`hypothesis=model-action-c1-feedback-v1`**. They report desired and
measured curvature, base heading, accumulated correction, applied heading,
feedback timing and driver/PSCM gating. `calibration_approved=false` remains.
Turning the toggle off and completing another offroad-to-onroad cycle restores
**PSCM Coefficient Observer** if selected, otherwise the original Ford path
controller. The stored observer selection is preserved. The experiment takes
priority on the supported vehicle; other vehicles retain their existing
controller. A leftover `FordVirtualAngleController` parameter has no effect.
## Wiring and validation
`controlsd` supplies the selected, upstream-limited desired curvature and the
measured steering-derived curvature already used in its tracking diagnostics.
Fresh steering publications advance C1 feedback. Repeated publications may
advance output slew but cannot integrate the same elapsed interval twice.
Driver override clears the correction. A fresh PSCM reached-limit flag stops
extra outward accumulation while preserving unwind and base model changes.
C0 retains the original 7 m model-path mapping. C2 and C3 remain zero. The
existing output limits, 100 Hz sender, Float32 publication and CAN builder
remain in place. No opendbc submodule or Panda safety change is required.
See [the feedback specification and validation](ford_c1_feedback.md) and
`ford_c1_feedback_validation.json` for the current evidence and reproduction
commands. `ford_model_action_validation.json` and
`ford_model_action_drive_test_validation.json` are historical records for the
original offline candidate and its first wiring, respectively; their counts
and coverage are not claims about the feedback version.
The full hardware build and device boot are not performed by these offline
checks. Pushing the branch does not install it on the device or change its
stored toggle.
@@ -1,145 +0,0 @@
{
"date": "2026-09-07",
"baseline_commit": "7ca3c6e3b3e659c6f446039501c5826bbd14092e",
"branch": "codex/ford-model-action-drive-test",
"scope": "Default-off Sunnylink selection and v8 retirement; offline validation only. No device installation or physical performance validation.",
"calibration_approved": false,
"production_selector_changed": true,
"toggle": "FordModelActionController",
"default_enabled": false,
"v8_removed": true,
"panda_safety_changed": false,
"opendbc_submodule_changed": false,
"deployment_opendbc_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9",
"controller_size": {
"total_lines": 145,
"code_lines_excluding_blanks_comments_docstrings": 95,
"core_persistent_values": 2,
"adapter_timestamps": 3,
"removed_v8_module_lines": 469
},
"tests": {
"combined_ford_params_sunnylink_suite": "284 passed, 26 subtests passed in 2.63s",
"suite_log_sha256": "2e223a507f0630481cf6f83b9f8893d226f3f4273a79a09fc35905aa875b1d2c",
"coverage": {
"covered_lines": 87,
"num_statements": 87,
"percent_covered": 100.0,
"percent_covered_display": "100",
"missing_lines": 0,
"excluded_lines": 0,
"percent_statements_covered": 100.0,
"percent_statements_covered_display": "100",
"num_branches": 26,
"num_partial_branches": 0,
"covered_branches": 26,
"missing_branches": 0,
"percent_branches_covered": 100.0,
"percent_branches_covered_display": "100"
},
"ruff": "pass",
"ty_controller_and_lab": "pass",
"settings_compiler_check": "pass",
"standards_review_remaining_findings": 0,
"spec_review_remaining_findings": 0,
"resolved_review_finding": "Updated YAML authoring source and regenerated settings JSON before final compiler/schema suite."
},
"routes": {
"route95": {
"cycles": 54738,
"core_active_cycles": 37614,
"core_exact_archived_match": true,
"cohorts_reproduced": true,
"adapter_active_cycles": 37614,
"adapter_exact_match_with_fresh_engagement_dt": true,
"adapter_validity_differs_from_archive_cycles": 0,
"adapter_command_differs_from_archive_cycles": 59,
"adapter_max_absolute_command_difference_c0_c1": [
0.010000000000000675,
0.0010000000000000009
],
"field_slew_zero_c2_c3_pass": true,
"float32_can_round_trips": 109476,
"opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7",
"report_sha256": "72fab710dc81c8c7d9b75d371b97fec32402d3b01fa05517dfa814d8daff3134"
},
"route90": {
"cycles": 78812,
"core_active_cycles": 73055,
"core_exact_archived_match": true,
"cohorts_reproduced": true,
"adapter_active_cycles": 73055,
"adapter_exact_match_with_fresh_engagement_dt": true,
"adapter_validity_differs_from_archive_cycles": 0,
"adapter_command_differs_from_archive_cycles": 19,
"adapter_max_absolute_command_difference_c0_c1": [
0.020000000000000462,
0.0020000000000000018
],
"field_slew_zero_c2_c3_pass": true,
"float32_can_round_trips": 157624,
"opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7",
"report_sha256": "cc2224bae597a341697a7681560a077cb209d77e4d060c0850997691c57d32fb"
}
},
"stress": {
"seed": 20260907,
"random_cycles": 200000,
"mirrored_core_updates": 200000,
"invalid_or_inactive_resets": 3537,
"field_boundary_cases": 18138,
"float32_can_round_trips": 218138,
"analytic_targets_scalar_slew_and_mirror_checks_pass": true,
"direct_raw_float32_packing_matches_host_output": true,
"max_continuous_step_c0_c1": [
0.40000000000000147,
0.05000000000000002
],
"calibration_approved": false,
"scope": "Numerical construction only; no PSCM response or closed-loop performance claims.",
"opendbc_import_head": "c21a9013700734dd20b09e05aa68329ad8cc20f9"
},
"total_float32_can_round_trips": 485238,
"native_params": {
"source": "Rebuilt locally from this branch with clang++ and generated Capnp headers; ignored test dependency, not committed binary.",
"library_sha256": "270bf43241cf7c02cc432cf78ec9411a62d7653ca445695efe785ae82241aa09",
"sources_sha256": {
"openpilot/common/params_c.cc": "57e3bcc7eba939bc91aadafb4ed1248b8123a8fe5c48fd8530298d966ea4db63",
"openpilot/common/params.cc": "a5adacb1d47cb3bf6e0d87d44ce158b41982d7eaf2e8114e32c48d3a6631304c",
"openpilot/common/params.h": "ed03d137e126ecd6f1608016020af18c0339fb987e27d0a2aa6830bba396970c",
"openpilot/common/params_keys.h": "39d36465f66405843b926ba18473fb6aee81c0f1c7bea87246aa08ffe3f67c58",
"openpilot/common/util.cc": "4479ecf72465e8f453d8af78447f7715f02d9397c58a49048f2bbc87a96d6b8a",
"openpilot/common/swaglog.cc": "9c2f88a2f1c3c4253b73defb264cc367a13ade23e02928e1d469b5c5833df176"
}
},
"test_dependency_notes": {
"python": "/Users/ibpersonal/dev/sunnypilot/.venv/bin/python",
"pyyaml": "6.0.3 from local uv cache",
"jsonschema": "Local cached package appended after venv to run schema validator without skips",
"hardware_build_and_device_boot": "not performed"
},
"source_sha256": {
"docs/ford_model_action_candidate.md": "c968132348d20891a9396f6e69db1315d570505796e19e09ffbb2da748e6e687",
"docs/ford_virtual_angle_experiment.md": "da6322f3c3d2d81463e44c50cc6cad1a962f97008ff9425c314da333ebe47a87",
"openpilot/common/params_c.cc": "57e3bcc7eba939bc91aadafb4ed1248b8123a8fe5c48fd8530298d966ea4db63",
"openpilot/common/params_keys.h": "39d36465f66405843b926ba18473fb6aee81c0f1c7bea87246aa08ffe3f67c58",
"openpilot/common/tests/test_params.py": "557a1f616af5fd9f5e623fbee0fd6f44cb059a30c290c68ce2b57e9bcceed081",
"openpilot/selfdrive/controls/controlsd.py": "102b383e5beff43b8dd7c219178bef62e8a4b54443ebe682694606862bcd4e7f",
"openpilot/selfdrive/controls/lib/ford_model_action.py": "8f3bc5d68e0051776f614a2ccffae84a88f7898dc95bdc12c23dcfe10dfe676a",
"openpilot/selfdrive/controls/tests/test_ford_controlsd_logging.py": "1c9448d88d8021e5d34a5dccd14a17c6c1bc64b5531342bfc6d15574d9d3e710",
"openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py": "2c5b14f814e84e59d749f61a43ef1dcfe06253f6e185443fa123c37525ac8466",
"openpilot/sunnypilot/sunnylink/settings_ui.json": "9974df3ac4cc58ae78d47848cd18ef4aca1bcbb00edb257f28b4220d92890528",
"openpilot/sunnypilot/sunnylink/settings_ui_src/pages/vehicle.yaml": "7a3fa18e562d5a03a5b85c72ee3f3ebeb836c497285dcd9aaad24f8fb4dd6942",
"openpilot/sunnypilot/sunnylink/tests/test_settings_schema.py": "3db566612381fd87d3655a3ccff470be7da998c4ea7f6365f53e58cdb9c0ffb7",
"tools/ford_pscm_lab/model_action_replay.py": "827a6dc488d554bdf6e87438c6a2a985b3195bf6d01ab09002bfd6049d22a868",
"tools/ford_pscm_lab/stress_model_action.py": "2d5c72cc4b8ae214f2f5a19a050fe138f5c2ab0294d92d24e2481af5f8185613",
"tools/ford_pscm_lab/test_model_action_replay.py": "ebf6bcd9260745100311521f8e11e85b7aebdd5561ab0876bfc2e802429d6896",
"openpilot/selfdrive/controls/tests/test_ford_model_action_selection.py": "f826c6328f0abac2a61f1a0a6f8d119fdbab858e363cb466d84ba9a7783059cd",
"docs/ford_model_action_drive_test.md": "d825b177cd099efd797fe89b7041695d6d41e4b9e8bba6bcaeb32aece164262b"
},
"deployment_target": {
"repository": "sunnypilot/sunnypilot",
"branch": "hiimisaac-dev",
"validated_code_commit": "ea1ed70c718d32539ef6b9a89b89c0e297c92e06"
}
}
-220
View File
@@ -1,220 +0,0 @@
{
"date": "2026-09-07",
"baseline_commit": "c4b3c55c826fca1ce09618e418e95f0a24478d96",
"calibration_approved": false,
"production_selector_changed": false,
"vehicle_settings_changed": false,
"panda_safety_changed": false,
"scope": "Offline command construction, adapter integration, numerical fault probes and Ford regression tests. Physical response remains unvalidated.",
"controller_size": {
"total_lines": 131,
"code_lines_excluding_blanks_comments_docstrings": 86,
"core_persistent_values": 2,
"adapter_timestamps": 3
},
"opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7",
"tests": {
"ford_suite": "264 passed, 150 subtests passed in 14.29s",
"new_core_adapter_tests": 107,
"new_replay_validator_tests": 13,
"controller_coverage": {
"statements": 78,
"missing_statements": 0,
"branches": 24,
"partial_branches": 0,
"percent": 100
},
"ruff": "pass",
"ty_controller_and_lab": "pass"
},
"routes": {
"route95": {
"cycles": 54738,
"core_active_cycles": 37614,
"core_exact_archived_match": true,
"cohorts_reproduced": true,
"adapter_active_cycles": 37614,
"adapter_status_counts": {
"inactive": 17124,
"active": 37614
},
"adapter_exact_match_with_fresh_engagement_dt": true,
"core_active_path_shorter_than_7m_cycles": 44,
"adapter_validity_differs_from_archive_cycles": 0,
"adapter_command_differs_from_archive_cycles": 59,
"adapter_max_absolute_command_difference_c0_c1": [
0.010000000000000675,
0.0010000000000000009
],
"field_slew_zero_c2_c3_pass": true,
"float32_can_round_trips": 109476,
"turn_speed_15_55": {
"seconds": 33.332073582999925,
"core_c0_c1_rms": [
0.07257996778378971,
0.03628926246224237
],
"recorded_v8_c0_c1_rms": [
0.3416081588451539,
0.027322786376822172
],
"adapter_eligible_seconds": 33.332073582999925,
"adapter_c0_c1_rms": [
0.07257996778378971,
0.03628926246224237
]
},
"input_sha256": {
"route.npz": "6e5438867ea618e53ca395b60ff4b9b146256f1bf6f07dc8e92d663286074154",
"encoder_comparison.npz": "23bd05c4b6400299844acaba1d97051c96c23c682bf17b46e89e7f2cca5fce38",
"pose_replay.npz": "9cfbb3c6f0b1fdb7e3d38e6b64b8c94cffbcc9fabe41f6344d84a9b657b6af9b"
},
"report_sha256": "cf4e3804f2ebdb0e50f3c49636bf60a30e3c1180411b582826a115970ab972fc"
},
"route90": {
"cycles": 78812,
"core_active_cycles": 73055,
"core_exact_archived_match": true,
"cohorts_reproduced": true,
"adapter_active_cycles": 73055,
"adapter_status_counts": {
"inactive": 5757,
"active": 73055
},
"adapter_exact_match_with_fresh_engagement_dt": true,
"core_active_path_shorter_than_7m_cycles": 0,
"adapter_validity_differs_from_archive_cycles": 0,
"adapter_command_differs_from_archive_cycles": 19,
"adapter_max_absolute_command_difference_c0_c1": [
0.020000000000000462,
0.0020000000000000018
],
"field_slew_zero_c2_c3_pass": true,
"float32_can_round_trips": 157624,
"turn_speed_15_55": {
"seconds": 47.53485040600012,
"core_c0_c1_rms": [
0.08686835454709245,
0.043216347944464766
],
"recorded_v8_c0_c1_rms": [
0.4471065308273131,
0.030663943784303503
],
"adapter_eligible_seconds": 47.53485040600012,
"adapter_c0_c1_rms": [
0.08686835454709245,
0.043216347944464766
]
},
"input_sha256": {
"route.npz": "51e8c26eedde253e171af47d704c1967ba45ae6825d883393bec1fb9e00251c1",
"encoder_comparison.npz": "7a625d3ed5cbd8013d1028aa3bc421740551dcae5c3d60981208bd047af9794c",
"pose_replay.npz": "4457ccc0868354749da5b72c1dea0faf750f783dfdc87038101288fdcba1e707"
},
"report_sha256": "b95247bdf6bbec16e5dc4781eaf7a678aca787418251cd161f6438e3173ad490"
}
},
"stress": {
"seed": 20260907,
"random_cycles": 200000,
"mirrored_core_updates": 200000,
"invalid_or_inactive_resets": 3537,
"field_boundary_cases": 18138,
"float32_can_round_trips": 218138,
"analytic_targets_scalar_slew_and_mirror_checks_pass": true,
"direct_raw_float32_packing_matches_host_output": true,
"max_continuous_step_c0_c1": [
0.40000000000000147,
0.05000000000000002
],
"calibration_approved": false,
"scope": "Numerical construction only; no PSCM response or closed-loop performance claims.",
"opendbc_import_head": "72a775d35e54c21ff5c5798acef22016eedcc0a7"
},
"mutation_checks": {
"mutations": [
{
"mutation": "halve_heading",
"detected_by_tests": true,
"failed_tests": 9
},
{
"mutation": "cap_heading_preview",
"detected_by_tests": true,
"failed_tests": 9
},
{
"mutation": "erase_centering",
"detected_by_tests": true,
"failed_tests": 15
},
{
"mutation": "slow_c0_to_c1_rate",
"detected_by_tests": true,
"failed_tests": 11
},
{
"mutation": "retain_invalid_state",
"detected_by_tests": true,
"failed_tests": 18
},
{
"mutation": "ignore_model_freshness",
"detected_by_tests": true,
"failed_tests": 2
},
{
"mutation": "ignore_model_clock_rollback",
"detected_by_tests": true,
"failed_tests": 1
},
{
"mutation": "reverse_c0_wire_sign",
"detected_by_tests": true,
"failed_tests": 11
}
],
"all_detected": true
},
"native_test_dependency": {
"scope": "Native dependency for inherited Params selection test only; copied existing local build, not rebuilt.",
"source_library": "/Users/ibpersonal/.codex/worktrees/3548/sunnypilot/openpilot/common/libparams_c.dylib",
"sha256": "ddde738108eab18b75f085c865c43aa197fd79a0384b390b1515ff90116e20e0",
"byte_identical_source_files": [
"openpilot/common/params.cc",
"openpilot/common/params.h",
"openpilot/common/params_c.cc",
"openpilot/common/params.py",
"openpilot/common/params_keys.h",
"openpilot/common/queue.h",
"openpilot/common/util.cc",
"openpilot/common/util.h",
"openpilot/common/hardware/hw.h"
]
},
"review": {
"standards_findings": 0,
"spec_findings": 0,
"method": "Independent parallel read-only reviews; 120 focused tests independently passed."
},
"source_sha256": {
"openpilot/selfdrive/controls/lib/ford_model_action.py": "cb6353f00f2f5c84df4e606c6b7e20650f8c1e908b9fd71890f72aa4a5e42592",
"openpilot/selfdrive/controls/tests/test_ford_model_action.py": "c3b971622cc4041575aeec1826d45b76cebab9a2f77b2b9525c85d4184961295",
"openpilot/selfdrive/controls/tests/test_ford_model_action_adapter.py": "5aaa29c1f11080b7df0165fd0202a29b065053809e5f9c4b9ed8abfcce47e41b",
"tools/ford_pscm_lab/__init__.py": "db4b8b7d2e317ed34ca0ec220bf9d53e7b80a23e766f4e1cb2c8224dada45f2e",
"tools/ford_pscm_lab/model_action_replay.py": "c114c479bd22e4fc61a3e8d3ee7fae5d71d1d80ec4b952faad8f8f3692fb1508",
"tools/ford_pscm_lab/stress_model_action.py": "a78a50eed1f801f3b096d694ab8c2fd70804b6c250465b4152f38d83770a982b",
"tools/ford_pscm_lab/test_model_action_replay.py": "09d024c59d44b83ec081d6416d0f946a7719a73f22213af1f4ddc03dc4e6f4ac"
},
"artifacts": {
"directory": ".cache/ford_model_action",
"route_reports": [
"route95/report.json",
"route90/report.json"
],
"stress_report": "stress.json",
"mutation_report": "mutations/report.json",
"test_log": "ford_suite.txt"
}
}
-327
View File
@@ -1,327 +0,0 @@
# Ford C2-free model-pose tracking with measured feedback
This experiment is retired. Its implementation, setting and dedicated tests
were removed from the selected-action drive-test branch. For current setup,
see [Ford selected-action drive testing](ford_model_action_drive_test.md).
The material below is historical; it does not describe an available toggle.
Hypothesis `model-pose-c0-c1-feedback-v8` retains the model-pose C0/C1 base
and adds two guarded release policies. When measured turning exceeds both
current and delayed requests, a separate output guard prevents same-direction
C0/C1 growth, including while feedback history rebuilds after driver input.
When turning instead falls below both requests and is no longer increasing,
bounded C1 tracking can use remaining release-entry command headroom.
Existing opposing-bias recovery still stops at zero bias. Geometry, blending,
feedback gain, slew rates and field limits are unchanged; C2/C3 remain zero.
This is an experimental outer controller around the multivariable PSCM.
Its geometry does not define a calibrated C0/C1-to-wheel mapping or an angle
servo. V8 has offline validation only. Command replay cannot establish the
truck's response, closed-loop stability, or an overshoot improvement.
## Evidence and scope
Route80 ran v3 and contains both sustained under-response and over-response.
Representative eligible windows had median CAN response/request ratios of
0.78, 1.77 and 0.69 with a declared 0.2-second comparison interval. These
are descriptive tracking ratios, not identified controller gains.
V4 replaced separate model-heading C1 with selected-curvature C1 and reduced
heading demand in several large maneuvers. The user subsequently reported
weak turning and steering repeatedly stopping near 85 degrees. Older logs
contain larger wheel angles; the inspected host code has no fixed 85-degree
wheel stop, although upstream curvature limits depend on speed.
Route83 had the Sunnylink toggle on, but omitted EPS firmware responses.
The former firmware gate selected the default `FordPathController`; replay
reproduced its recorded C0/C1/C2 requests. Its favorable turns are evidence
for the existing model-pose construction, not validation of v5 or v6.
V6 reuses that construction while replacing its remaining C2 request with
C0/C1 geometry. Removing C2 changes the request received by the PSCM, so
matching large C0/C1 commands does not guarantee matching vehicle motion.
Route8a ran v6 and was reported as the best drive. Route8e ran v7 throughout
with the experiment enabled; it includes entry lag and excessive turning
while requests release. Fixed-input v6/v7 replay produced identical commands
in the main reversal and over-response examples, so the v7 recovery change
does not directly explain their command behavior. In the over-response
example, model C0/C1 grew while selected curvature fell and driver resets
repeatedly removed feedback history. Another exit remained deficient after
opposing bias reached zero. These observations motivate the v8 guards; they
do not isolate an EPS transfer function or demonstrate the proposed response.
## Base request
controlsd selects valid `lateralManeuverPlan.desiredCurvature`, otherwise
`modelV2.action.desiredCurvature`, after the existing curvature limiter.
This action already includes upstream delay handling; it receives no extra
response advance here.
The model contribution uses the existing allocator's raw forward pose and
bounded short-pose correction. `_model_pose` advances 0.1 seconds, retains
the model's remaining forward geometry, and separately corrects the short
pose using measured curvature and its recent change. Its offset preview is
up to 7 m and its heading preview is up to max(7 m, speed × 1 s), bounded by
available path length. This raw pose is not passed through a second model
filter. The filtered, ego-aligned reference remains available for comparison
and the existing geometry-validity checks.
```text
share(k) = clip((k - 0.006/m) / (0.012/m - 0.006/m), 0, 1)
aligned = desired_curvature × model_forward_heading > 0
model_share = min(share(abs(desired_curvature)), share(model_curvature_demand))
if aligned, otherwise 0
model_pair = existing_pose_encoder(model_pose, model_share, C2=0)
remaining_curvature = desired_curvature × (1 - model_share)
L0 = max(8 m, speed × 1 s)
L1 = max(7 m, speed × 1 s)
curvature_C0 = 0.5 × remaining_curvature × L0²
curvature_C1 = remaining_curvature × L1
C0_base = clip(model_pair.C0 + curvature_C0, ±5.11 m)
C1_base = clip(model_pair.C1 + curvature_C1, ±0.5 rad)
```
`model_curvature_demand` is the larger absolute curvature implied by the
forward offset and heading previews. The share uses the existing allocator's
0.0060.012/m thresholds. Both model and action must request a substantial
turn in the same direction before model pose supplies the full base.
Small, flat, opposed or zero requests use the curvature contribution; zero
action produces a zero base. Partial shares combine both contributions.
The existing pose encoder retains its quantization and field-allocation rules.
The residual-curvature lift is geometric, not a claim of EPS equivalence to C2.
The inherited pose encoder allocates heading overflow using its asymmetric
limits (+0.5235/0.5 rad), before the symmetric final ±0.5 rad
heading bound. On clipped tails, this can leave mirrored C0 requests differing
by up to 0.0235 rad × 7 m = 0.1645 m. The favorable comparison anchors lie
below that heading cap; full model-base odd symmetry is not claimed.
## Measured feedback and limits
```text
past_request = selected curvature held at or before (measurement_time - delay)
yaw_error = measured_speed × past_request - measured_yaw_rate
bias_trial = released_bias + feedback_gain × yaw_error × measurement_dt
C1_unconstrained = clip(C1_base + accepted_bias, ±0.5 rad)
C1_target = temporary_backoff_ceiling(C1_unconstrained) if backoff_active
otherwise C1_unconstrained
```
Measured yaw is negated Ford CAN yaw, matching the control sign convention.
The historical request uses zero-order hold; it never interpolates toward a
future publication. Nominal comparison delay is `CP.steerActuatorDelay`
(0.2 seconds on the source vehicle). Feedback compares against selected
curvature, not curvature inferred from the model-pose coefficients.
| Quantity | Value |
|---|---:|
| C0 / C1 final bounds | ±5.11 m / ±0.5 rad |
| Independent C0 / C1 slew | 4 m/s / 0.5 rad/s |
| Feedback integration scale | 1.0 |
| Feedback minimum speed | 2 m/s |
| Maximum PSCM/core input age | 150 ms |
| Allowed timestamp lead | 5 ms |
| Release comparison tolerance | one C1 wire quantum, 0.0005 rad |
The integration scale, preview distances and blend thresholds are effective
gains; none establishes stability. No wheel-response gain is fitted.
Zero yaw error retains acquired bias while an eligible turn continues.
Host anti-windup admits reachable correction within the combined C1 field
and slew limits. Feedback overflow is not transferred into C0.
The release logic scales bias as the bounded base decreases and resets on
zero/reversal. When delayed curvature still represents a stronger or opposing
request, or PSCM reports LimitReached, new integration is normally frozen.
One exception permits measured-error backoff: measured turning must exceed
both the delayed and current selected yaw requests in the base's direction,
and total heading must still have the base's sign. Exceeding only an older,
smaller request during turn-in does not qualify. The accepted increment may
only reduce that existing total toward zero; it cannot grow the request or
carry it through zero. Existing host field and slew limits still apply.
The existing release-recovery exception requires fresh valid PSCM status with
limit below 2, retained bias opposing the base, and both current and delayed
requests aligned with that base. Measured turning must be below both requests
in their direction. It then uses the current yaw deficit × the existing
feedback gain × measurement interval to unwind only the opposing bias toward
zero. The increment is clipped so recovery cannot cross zero bias or create
demand beyond the existing base. Common host anti-windup still limits what
can be accepted. A separate release-tracking exception is described below;
other constrained cases remain frozen. PSCM limit 2 never permits either
request-increasing exception.
The no-new-bias restriction applies to `release_recovery`. It does not apply
to the separate bounded `release_tracking` branch. Once release ends,
ordinary eligible integration can add correction beyond the base as before;
its existing limits and guards are unchanged.
`release_recovery` and `feedback_recovery_active=true` indicate that the
recovery branch actually changed bias on that update. If host anti-windup
blocks the entire increment, the status remains `host_limit` and the flag is
false. Recovery is evaluated only on fresh measurements; the flag is false
on repeated-measurement updates and after reset.
Diagnostics distinguish `release_backoff` and `pscm_backoff`; a release takes
precedence when both conditions apply. While `feedback_backoff_active` is
true, total C1 is also capped at the preceding continuous heading request in
the current request direction and at zero in the opposite direction. This
ceiling affects the output only: it is not stored or projected into bias.
The measured-error increment can still update bias under the normal limits,
but a changing model base does not create persistent integral suppression.
The ceiling persists between repeated measurements; C1 cannot grow or reverse
while it applies. The next fresh measurement clears it unless backoff is
again warranted. It does not cap C0, and normal feedback has its own rules
outside backoff. Independent slew remains 0.5 rad/s for C1 and 4 m/s for C0.
Backoff still compares against the delayed reference, so response lag remains.
Reducing a request does not demonstrate that physical overshoot is resolved.
## V8 release guard and tracking
`ReleaseGuard` retains selected-request history independently of feedback
bias history. Driver-related feedback resets do not erase that reference,
but the guard still requires current fresh valid PSCM status, no current
driver override, and the existing input and speed eligibility. Invalid core
input or disengagement resets its history with the controller.
During release, measured yaw must exceed both the current and delay-matched
requests in the requested turn direction. Only then does the guard cap
same-direction C0/C1 growth at each preceding continuous request. Terms
already reducing the turn, including an opposing C0 centering offset, remain
available. The guard follows base allocation and C1 feedback, so changing
model geometry cannot bypass it. Its ceilings affect outputs, never stored
bias. No scalar-curvature cap replaces strong model geometry during turn-in
or undertracking. Existing independent slew and field limits still apply.
`release_tracking` addresses an eligible release deficit once bias is zero
or already in the base's direction. Both current and delayed requests must
align with that base, measured turning must be below both, and measured
curvature must not be rising in the turn direction across the response
interval by more than one C1 wire quantum after scaling by heading preview.
Fresh valid PSCM status with limit below 2 is required. The current yaw deficit
uses the existing integration gain and measurement interval;
new C1 tracking increments are limited by command headroom captured at
release entry, tapered with remaining desired curvature. The allowance is
`max(0, entry_command_magnitude - abs(base)) × min(1, abs(desired) / entry_reference)`
above the current base; any existing same-direction bias consumes it first.
This limits new tracking integration, not the existing model base or bias.
Only that additional allowance is tapered; strong model geometry remains
available. A brief pause does not reacquire a higher entry
ceiling; a full response interval without release ends the retained episode.
Common host anti-windup, field and slew bounds still apply. Opposing bias
continues through `release_recovery`, which stops at zero, before any separate
tracking exception can be considered.
Neither exception relaxes the PSCM LimitReached growth restriction. The
reference delay and finite response time remain; these output policies are
command-construction changes, not evidence of improved physical tracking.
## PSCM status and driver handling
card publishes `Lane_Assist_Data3_FD1` in `carStateSP.fordPscmStatus`, retaining
the original CAN receipt timestamp. Republishing carStateSP or receiving
unrelated frames cannot refresh it. The opendbc submodule is unchanged.
Feedback requires valid fresh status, InProgress lateral state (2), capability
LimitedModeAvailable or ExtendedModeAvailable (1 or 2), and no denial.
Missing, malformed, stale, backward-timestamped, denied or unavailable status
clears feedback bias/history and disables the separate release guard,
leaving the base subject to its core validity gates.
LimitReached (2) permits only the bounded request-reducing backoff described
above and otherwise freezes integration. LimitWithDriverActive (3) clears
feedback. Backoff still requires fresh, valid, InProgress status with an
available capability and no denial. These generic PSCM reports do not identify
a specific torque or rate limit.
`steeringPressed`, raw torque above the existing Ford driver allowance, or
nonfinite torque clear feedback. Below 2 m/s feedback also clears. A fresh
feedback reference interval is required after override; the independent
release guard can use retained valid request history once its current gates
are satisfied. Base requests retain normal
PSCM driver arbitration while lateral control remains authorized; an unset
override flag cannot rule out subthreshold driver influence.
## Gates and Sunnylink selection
Core model/action/car-state freshness, finite-value, clock and speed checks
remain in place. Invalid core inputs reset both commands and clear latActive.
Raw model geometry is validated on every update, including repeated model
timestamps; an invalid raw path cannot reuse the cached valid reference.
Missing PSCM status disables feedback, not an otherwise valid base request.
Vehicle → Ford → **C2-Free Path Tracking (Experimental)** retains the
`FordVirtualAngleController` key, default-off setting and offroad/onroad cycle
requirement. Enabled selects v8 on Ford CAN FD `FORD_F_150_LIGHTNING_MK1`
regardless of missing or different EPS firmware-query results. Other platforms
retain their existing controller. V8 takes priority over PSCM Coefficient
Observer while selected; disabling and cycling offroad/onroad restores the
previous selection. Controller selection does not force lateral engagement.
The analyzed firmware is `RL38-14D003-AA`; removing the eligibility check
is not validation of other firmware. No live device setting is changed.
## Diagnostics and verification
The 5 Hz `Ford C2-free path tracking` event keeps its name and identifies v8.
`model_offset_base` / `model_heading_base` report the already weighted and
encoded model contribution; `curvature_offset_base` / `curvature_heading_base`
report the residual-curvature contribution. `model_share` and `base_guard`
identify model-pose, blended, curvature-only, opposed-model and zero-request
cases. `heading_base` is the bounded pre-feedback C1. `offset_target` and
`heading_target` are the final targets after the independent release guard;
`offset_target_unguarded` and `heading_target_unguarded` retain the inputs to
that guard. The latter C1 already includes its normal feedback/backoff policy.
The event retains source timestamps, measured curvature/yaw, final commands,
slew scales, feedback bias/status/history, raw torque and PSCM status/age.
`feedback_backoff_active` records the persistent heading ceiling, including
cycles whose feedback status is `no_new_measurement`.
`release_guard_active` and `release_guard_reference_curvature` expose the
independent C0/C1 guard and its retained delayed reference.
`feedback_release_tracking_active`, `feedback_release_ceiling` and
`feedback_curvature_delta` identify accepted release
tracking, the total-heading threshold used to admit new bias, and the
measured-curvature change across the response interval (1/m). The tracking
flag is true only when the branch accepts a bias change on a new measurement;
it is false on repeated measurements. The ceiling/trend fields can describe
an evaluated condition even when no increment is accepted.
`feedback_recovery_active` records an accepted recovery increment on this
update only; it does not persist between measurements.
`feedback_yaw_error` retains its delayed-reference meaning. Recovery instead
uses current error, reconstructed from logged `desired_curvature`,
synchronized car-state speed and `yaw_rate`; those two errors can differ.
During backoff or the independent release guard, `heading_target` can be lower in the request direction than
the bounded sum of `heading_base` and `heading_bias`, because the temporary
ceiling is not part of the stored bias.
`model_heading_target` remains a filtered comparison reference; it is not the
weighted model contribution. `angleState.saturated` is not an EPS-limit signal.
Validation must cover large recorded maneuvers, flat-model centering, both
turn directions, model/action disagreement, share transitions, release and
reversal, release/limit backoff without growth or zero crossing, status/driver
resets, reference causality, bounds, slew and CAN packing with C2/C3 zero.
Recovery checks cover both directions, stopping at zero bias, repeated
measurements, current-and-delayed agreement, and rejection at PSCM limit 2.
Old v3/v4 command-equality expectations do not define
v8 success. Guard checks also cover driver reset/history rebuilding,
same-direction growth, opposing coefficients, repeated measurements,
undertracking and invalid-status inhibition. Tracking checks cover delayed
curvature trends and tapered release-entry headroom. Historical v5v7 replay
results remain historical observations.
The v8 recorded-input fixture contains 15,273 cycles with 4,879 selected
evidence samples. Base allocation and output eligibility match v7. In the
clean deficient exit, median absolute C1 changes from 0.0665 to 0.0845 rad
while C0 stays unchanged. The growth guard also acts while feedback history
rebuilds; the largest over-growth witness includes nearby driver input and
is excluded from the strict autonomous tracking score. Both good comparison
curves in that fixture retain their median requests, and the older large-turn
fixtures retain their required command scale.
On the earlier good drive, one comparison curve retains extra C1 after
eligible release tracking: median magnitude changes from 0.121 to 0.128 rad.
In its 103110 s interval, tracking increments occur only while measured
turning falls short, with a median current response/request ratio of 0.895.
Acquired bias can persist after matching, as with ordinary integral feedback.
This collateral command change remains a reason to compare new vehicle logs.
Replay fixes recorded motion and planner outputs, so enabled vehicle logs
are still required to assess tracking error, oscillation and interventions.
+1 -3
View File
@@ -24,9 +24,7 @@ function agnos_init {
if $AGNOS_PY --verify $MANIFEST; then
sudo reboot
fi
while true; do
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
done
$DIR/openpilot/common/hardware/comma/updater $AGNOS_PY $MANIFEST
fi
}
+1 -1
View File
@@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1
export QCOM_PRIORITY=12
if [ -z "$AGNOS_VERSION" ]; then
export AGNOS_VERSION="19.7"
export AGNOS_VERSION="19.6"
fi
export STAGING_ROOT="/data/safe_staging"
+1 -45
View File
@@ -131,7 +131,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
downloaded @2;
cached @3;
failed @4;
verifying @5;
}
struct DownloadProgress {
@@ -353,7 +352,6 @@ struct OnroadEventSP @0xda96579883444c35 {
speedLimitPending @22;
e2eChime @23;
laneChangeRoadEdge @24;
bigModelReady @25;
}
}
@@ -383,7 +381,6 @@ struct CarControlSP @0xa5cd762cd951a455 {
leadOne @2 :LeadData;
leadTwo @3 :LeadData;
intelligentCruiseButtonManagement @4 :IntelligentCruiseButtonManagement;
fordLateralPath @5 :FordLateralPath;
struct Param {
key @0 :Text;
@@ -404,14 +401,6 @@ struct CarControlSP @0xa5cd762cd951a455 {
}
}
struct FordLateralPath {
pathOffset @0 :Float32; # c0 [m]
pathAngle @1 :Float32; # c1 [rad]
curvature @2 :Float32; # c2 [1/m]
curvatureRate @3 :Float32; # c3 [1/m^2]
valid @4 :Bool;
}
struct BackupManagerSP @0xf98d843bfd7004a3 {
backupStatus @0 :Status;
restoreStatus @1 :Status;
@@ -456,16 +445,6 @@ struct BackupManagerSP @0xf98d843bfd7004a3 {
struct CarStateSP @0xb86e6369214c01c8 {
speedLimit @0 :Float32;
fordPscmStatus @1 :FordPscmStatus;
struct FordPscmStatus {
valid @0 :Bool;
canMonoTime @1 :UInt64; # Last accepted Lane_Assist_Data3_FD1 CAN receipt, not carStateSP publication time.
lateralState @2 :UInt8; # LatCtlSte_D_Stat
limit @3 :UInt8; # LatCtlLim_D_Stat: generic lateral limit, not a torque/rate diagnosis.
capability @4 :UInt8; # LatCtlCpblty_D_Stat
denied @5 :Bool; # LaActDeny_B_Actl
}
}
struct LiveMapDataSP @0xf416ec09499d9d19 {
@@ -489,30 +468,7 @@ struct ModelDataV2SP @0xa1680744031fdb2d {
}
}
struct AssistedDrivingMilestoneState @0xcb9fd56c7057593a {
enabled @0 :Bool;
madsDistanceMeters @1 :Float64;
fullAssistDistanceMeters @2 :Float64;
event @3 :Event;
struct Event {
id @0 :UInt64;
category @1 :Category;
distanceMeters @2 :Float64;
previousDistanceMeters @3 :Float64;
unit @4 :Unit;
}
enum Category {
none @0;
mads @1;
fullAssist @2;
}
enum Unit {
imperial @0;
metric @1;
}
struct CustomReserved10 @0xcb9fd56c7057593a {
}
struct CustomReserved11 @0xc2243c65e0340384 {
+1 -3
View File
@@ -725,7 +725,6 @@ struct ChestnutState {
pcieLtssm @7 :UInt8;
supplyVoltage @8 :UInt16; # mV
supplyCurrent @9 :Int16; # mA
supplyFault @10 :Bool;
}
struct RadarState @0x9a185389d6fdd05f {
@@ -1005,7 +1004,6 @@ struct DrivingModelData {
frameIdExtra @1 :UInt32;
frameDropPerc @6 :Float32;
modelExecutionTime @7 :Float32;
big @8 :Bool;
action @2 :ModelDataV2.Action;
@@ -2642,7 +2640,7 @@ struct Event {
carStateSP @114 :Custom.CarStateSP;
liveMapDataSP @115 :Custom.LiveMapDataSP;
modelDataV2SP @116 :Custom.ModelDataV2SP;
assistedDrivingMilestoneState @136 :Custom.AssistedDrivingMilestoneState;
customReserved10 @136 :Custom.CustomReserved10;
customReserved11 @137 :Custom.CustomReserved11;
customReserved12 @138 :Custom.CustomReserved12;
customReserved13 @139 :Custom.CustomReserved13;
-1
View File
@@ -90,7 +90,6 @@ _services: dict[str, tuple] = {
"carParamsSP": (True, 0.02, 1),
"carControlSP": (True, 100., 10),
"carStateSP": (True, 100., 10),
"assistedDrivingMilestoneState": (True, 10., 1),
"liveMapDataSP": (True, 1., 1),
"modelDataV2SP": (True, 20., None, QueueSize.BIG),
"liveLocationKalman": (True, 20.),
+11 -11
View File
@@ -56,29 +56,29 @@
},
{
"name": "boot",
"url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz",
"hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
"hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
"url": "https://commadist.azureedge.net/agnosupdate/boot-b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd.img.xz",
"hash": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd",
"hash_raw": "b30f5eef65ec3878f3aa3dcaf2cc95c09e2c1e661cd3a38e94da37dee76f68bd",
"size": 46897152,
"sparse": false,
"full_check": true,
"has_ab": true,
"ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808"
"ondevice_hash": "6650e4c46df99ae6dfd6ee895a34b8a2a3cc490a8ce18e16cc3c451c3f822b6e"
},
{
"name": "system",
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz",
"hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744",
"hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
"url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img.xz",
"hash": "b134fd04e9da27fa1d359ea0f2742c216fa21a08b5c47e9be22ab3b0563d9b9b",
"hash_raw": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3",
"size": 4718592000,
"sparse": true,
"full_check": false,
"has_ab": true,
"ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448",
"ondevice_hash": "91242772af771ae96fe2eebc105f2b80a7e1dbaaf6003c2574b62d51b806f468",
"alt": {
"hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img",
"hash": "5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3",
"url": "https://commadist.azureedge.net/agnosupdate/system-5b6ce7965904a157fd3a134ccfcb854f9ca5c1cc2a26b7cb80a4fa4e1cc4aaa3.img",
"size": 4718592000
}
}
]
]
+1 -2
View File
@@ -5,7 +5,6 @@ import logging
import os
import select
import signal
import string
import struct
import subprocess
import tempfile
@@ -355,7 +354,7 @@ class Modem:
imei = ""
iccid = (self._atv("AT+QCCID", "+QCCID:") or "").rstrip("F")
if not all(c in string.hexdigits for c in iccid):
if not iccid.isdigit():
iccid = ""
imsi = first_line("AT+CIMI")
+1 -7
View File
@@ -4,17 +4,11 @@ from pathlib import Path
CHESTNUT_FW_VERSION = "ed4e39b7"
CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN"
USB_DEVICES_PATH = Path("/sys/bus/usb/devices")
TYPEC_CC_ORIENTATION_PATH = Path("/sys/class/power_supply/usb/typec_cc_orientation")
PRIMARY_USB_CONTROLLER = "a600000.ssusb"
def is_chestnut_usb_id(vendor_id: int, product_id: int, include_bootloader: bool = False) -> bool:
ids = CHESTNUT_USB_IDS + CHESTNUT_ROM_USB_IDS if include_bootloader else CHESTNUT_USB_IDS
return (vendor_id, product_id) in ids
def get_usb_topology() -> set[str]:
try:
return set(os.listdir(USB_DEVICES_PATH))
@@ -87,7 +81,7 @@ def set_usb_state(device_state, devices: list[dict]) -> None:
entry.linkErrorCount = device["linkErrorCount"]
entry.usb3Lane = device.get("usb3Lane", "unknown")
if is_chestnut_usb_id(entry.vendorId, entry.productId):
if (entry.vendorId, entry.productId) in CHESTNUT_USB_IDS:
chestnut_present = True
device_state.chestnutPresent = chestnut_present
-4
View File
@@ -97,10 +97,6 @@ Params::Params(const std::string &path) {
}
Params::~Params() {
flushNonBlockingWrites();
}
void Params::flushNonBlockingWrites() {
if (future.valid()) {
future.wait();
}
-1
View File
@@ -75,7 +75,6 @@ public:
return put(key.c_str(), val ? "1" : "0", 1);
}
void putNonBlocking(const std::string &key, const std::string &val);
void flushNonBlockingWrites();
inline void putBoolNonBlocking(const std::string &key, bool val) {
putNonBlocking(key, val ? "1" : "0");
}
-5
View File
@@ -73,7 +73,6 @@ params_get = _bind("params_get", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool],
params_get_bool = _bind("params_get_bool", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool], ctypes.c_bool)
params_put = _bind("params_put", [ParamsHandle, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_size_t, ctypes.c_bool], ctypes.c_int)
params_put_bool = _bind("params_put_bool", [ParamsHandle, ctypes.c_char_p, ctypes.c_bool, ctypes.c_bool], ctypes.c_int)
params_flush = _bind("params_flush", [ParamsHandle])
params_remove = _bind("params_remove", [ParamsHandle, ctypes.c_char_p], ctypes.c_int)
params_get_path = _bind("params_get_path", [ParamsHandle, ctypes.c_char_p, ctypes.c_size_t], ParamsBuffer)
params_keys_size = _bind("params_keys_size", [ParamsHandle], ctypes.c_size_t)
@@ -179,10 +178,6 @@ class Params:
def put_bool(self, key, val, block=False):
params_put_bool(self.p, self.check_key(key), val, block)
def flush(self):
"""Wait for all prior nonblocking writes from this Params instance."""
params_flush(self.p)
def remove(self, key):
params_remove(self.p, self.check_key(key))
+5 -14
View File
@@ -133,12 +133,6 @@ int params_put_bool(ParamsHandle *handle, const char *key, bool value, bool bloc
});
}
void params_flush(ParamsHandle *handle) noexcept {
translate_exceptions([&]() {
handle->params.flushNonBlockingWrites();
});
}
int params_remove(ParamsHandle *handle, const char *key) noexcept {
return translate_exceptions(-1, [&]() {
return handle->params.remove(key);
@@ -168,15 +162,12 @@ ParamsBuffer params_key_at(ParamsHandle *handle, size_t index) noexcept {
size_t params_keys_by_flag(ParamsHandle *handle, uint32_t flag, ParamsBuffer *out, size_t out_size) noexcept {
return translate_exceptions(size_t{0}, [&]() {
size_t count = 0;
for (const auto &key : handle->keys) {
if (flag == ALL || (handle->params.getKeyFlag(key) & flag)) {
// Each buffer borrows a different string, stable for the handle's lifetime.
if (count < out_size) out[count] = {key.data(), key.size()};
++count;
}
auto filtered = handle->params.allKeys(static_cast<ParamKeyFlag>(flag));
size_t count = std::min(filtered.size(), out_size);
for (size_t i = 0; i < count; i++) {
out[i] = return_string(filtered[i]);
}
return count;
return filtered.size();
});
}
+7 -24
View File
@@ -59,7 +59,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsDriverViewEnabled", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsEngaged", {PERSISTENT, BOOL}},
{"IsLdwEnabled", {PERSISTENT | BACKUP, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}},
{"IsLiveStreaming", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsMetric", {PERSISTENT | BACKUP, BOOL}},
{"IsOffroad", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsRhdDetected", {PERSISTENT, BOOL}},
@@ -92,12 +92,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"ObdMultiplexingEnabled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
{"Offroad_CarUnrecognized", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutBranch", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutNotDetected", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutOverheated", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutPcieUnavailable", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ChestnutUncompiled", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutUpdateFailed", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ChestnutUsbSlow", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, JSON}},
{"Offroad_ConnectivityNeeded", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ConnectivityNeededPrompt", {CLEAR_ON_MANAGER_START, JSON}},
{"Offroad_ExcessiveActuation", {PERSISTENT, JSON}},
@@ -136,15 +130,12 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"UpdaterLastFetchTime", {PERSISTENT, TIME}},
{"UptimeOffroad", {PERSISTENT, FLOAT, "0.0"}},
{"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}},
{"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"ChestnutModelError", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"UsbGpuActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"UsbGpuLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
{"Version", {PERSISTENT, STRING}},
// --- sunnypilot params --- //
{"ApiCache_DriveStats", {PERSISTENT, JSON}},
{"AssistedDrivingMilestonesEnabled", {PERSISTENT | BACKUP, BOOL, "1"}},
{"AssistedDrivingMilestoneState", {PERSISTENT, JSON, "{}"}},
{"AutoLaneChangeBsmDelay", {PERSISTENT | BACKUP, BOOL, "0"}},
{"AutoLaneChangeTimer", {PERSISTENT | BACKUP, INT, "0"}},
{"BlinkerLateralReengageDelay", {PERSISTENT | BACKUP, INT, "0"}}, // seconds
@@ -165,7 +156,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"DevUIInfo", {PERSISTENT | BACKUP, INT, "0"}},
{"EnableCopyparty", {PERSISTENT | BACKUP, BOOL}},
{"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}},
{"FullAssistDrivenDistanceMeters", {PERSISTENT, FLOAT, "0.0"}},
{"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
{"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}},
{"HasAcceptedTermsSP", {PERSISTENT, STRING, "0"}},
@@ -175,9 +165,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"IsDevelopmentBranch", {CLEAR_ON_MANAGER_START, BOOL}},
{"IsReleaseSpBranch", {CLEAR_ON_MANAGER_START, BOOL}},
{"LastGPSPositionLLK", {PERSISTENT, STRING}},
{"LastDriveAssistedDrivingSummary", {PERSISTENT, JSON, "{}"}},
{"LeadDepartAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
{"MadsDrivenDistanceMeters", {PERSISTENT, FLOAT, "0.0"}},
{"MaxTimeOffroad", {PERSISTENT | BACKUP, INT, "1800"}},
{"ModelRunnerTypeCache", {CLEAR_ON_ONROAD_TRANSITION, INT}},
{"OffroadMode", {CLEAR_ON_MANAGER_START, BOOL}},
@@ -207,16 +195,14 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// Model Manager params
{"ModelManager_ActiveBundle", {PERSISTENT, JSON}},
{"ModelManager_ActiveBundleUSBGPU", {PERSISTENT, JSON}}, //TODO-SP: kept for migration, remove on next sync?
{"ModelManager_ActiveBundleChestnut", {PERSISTENT, JSON}},
{"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, JSON}},
{"ModelManager_ActiveJson", {CLEAR_ON_MANAGER_START, STRING}},
{"ModelManager_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"ModelManager_DownloadRef", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, STRING}},
{"ModelManager_DownloadIndex", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, INT}},
{"ModelManager_Favs", {PERSISTENT | BACKUP, STRING}},
{"ModelManager_LastSyncTime", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_LastSyncTime_Chestnut", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_LastSyncTime_USBGPU", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, INT, "0"}},
{"ModelManager_ModelsCache", {PERSISTENT | BACKUP, JSON}},
{"ModelManager_ModelsCache_Chestnut", {PERSISTENT | BACKUP, JSON}},
{"ModelManager_ModelsCache_USBGPU", {PERSISTENT | BACKUP, JSON}},
// Neural Network Lateral Control
{"NeuralNetworkLateralControl", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -237,8 +223,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
{"BackupManager_RestoreVersion", {PERSISTENT, STRING}},
// sunnypilot car specific params
{"FordPscmObserver", {PERSISTENT | BACKUP, BOOL, "0"}},
{"FordModelActionController", {PERSISTENT | BACKUP, BOOL, "0"}},
{"HyundaiLongitudinalTuning", {PERSISTENT | BACKUP, INT, "0"}},
{"SubaruStopAndGo", {PERSISTENT | BACKUP, BOOL, "0"}},
{"SubaruStopAndGoManualParkingBrake", {PERSISTENT | BACKUP, BOOL, "0"}},
@@ -261,7 +245,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
// mapd
{"MapAdvisorySpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT}},
{"Mapd_ClearCache", {CLEAR_ON_MANAGER_START, BOOL}},
{"MapdVersion", {PERSISTENT, STRING}},
{"MapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, FLOAT, "0.0"}},
{"NextMapSpeedLimit", {CLEAR_ON_ONROAD_TRANSITION, JSON}},
+4 -4
View File
@@ -27,14 +27,14 @@ public:
auto param_path = Params().getParamPath();
if (util::file_exists(param_path)) {
std::string real_path = util::readlink(param_path);
util::check_system(util::string_format("rm -rf %s", real_path.c_str()));
util::check_system(util::string_format("rm %s -rf", real_path.c_str()));
unlink(param_path.c_str());
}
if (getenv("COMMA_CACHE") == nullptr) {
util::check_system(util::string_format("rm -rf %s", Path::download_cache_root().c_str()));
util::check_system(util::string_format("rm %s -rf", Path::download_cache_root().c_str()));
}
util::check_system(util::string_format("rm -rf %s", Path::comma_home().c_str()));
util::check_system(util::string_format("rm -rf %s", msgq_path.c_str()));
util::check_system(util::string_format("rm %s -rf", Path::comma_home().c_str()));
util::check_system(util::string_format("rm %s -rf", msgq_path.c_str()));
unsetenv("OPENPILOT_PREFIX");
}
-17
View File
@@ -106,13 +106,6 @@ class TestParams(OpenpilotTestCase):
assert q.get("CarParams") is None
assert q.get("CarParams", True) == b"1"
def test_flush_non_blocking_writes(self):
self.params.put("DongleId", "first")
self.params.put("DongleId", "last")
self.params.flush()
assert self.params.get("DongleId") == "last"
def test_params_all_keys(self):
keys = Params().all_keys()
@@ -133,16 +126,6 @@ class TestParams(OpenpilotTestCase):
assert self.params.get("LiveParametersV2") is None
assert self.params.get("LiveParametersV2", return_default=True) is None
def test_filtered_keys_are_distinct_registered_strings(self):
registered = set(self.params.all_keys())
for flag in (ParamKeyFlag.PERSISTENT, ParamKeyFlag.BACKUP, ParamKeyFlag.CLEAR_ON_MANAGER_START):
filtered = self.params.all_keys(flag)
assert len(filtered) > 1
assert len(filtered) == len(set(filtered))
assert set(filtered) <= registered
assert all(key.decode('utf-8') for key in filtered)
assert self.params.all_keys(flag) == filtered
def test_params_get_type(self):
# json
self.params.put("ApiCache_FirehoseStats", {"a": 0}, block=True)
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:845c40ff0d37612e8f2f482a36845744b5ae91ce2fcfc8117990d7d278b59820
size 13079
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a8c5fece2a1c7587feb41cbe04c6aee08e768ecd9b5d00da6af9832a4ccc842
size 2034
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7409c53d7c72681c24982fd83b56ce70f80797c9c0f936d9296a5c18557ac472
size 7279
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58bd6155433f623b1f75d134bd8ca4745d9aa71f6767eb807cdbcf7deb3089a1
size 10876
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07bda2fe5d6be0b2854044053c384fe002e96406da119863a443b9344258b500
size 1544
Binary file not shown.
-2
View File
@@ -21,7 +21,6 @@ from opendbc.car.interfaces import CarInterfaceBase, RadarInterfaceBase
from openpilot.selfdrive.pandad import can_capnp_to_list, can_list_to_can_capnp
from openpilot.selfdrive.car.cruise import VCruiseHelper
from openpilot.selfdrive.car.helpers import convert_carControlSP, convert_to_capnp
from openpilot.selfdrive.car.ford_pscm_status import populate_ford_pscm_status
from openpilot.sunnypilot.mads.helpers import set_alternative_experience, set_car_specific_params
from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces
@@ -199,7 +198,6 @@ class Car:
# Update carState from CAN
CS, CS_SP = self.CI.update(can_list)
CS_SP = convert_to_capnp(CS_SP)
populate_ford_pscm_status(self.CP, self.CI.can_parsers, CS_SP, CS.canValid)
# Update radar tracks from CAN
RD: structs.RadarDataT | None = self.RI.update(can_list)
@@ -1,36 +0,0 @@
"""Publish the Ford PSCM's actual CAN status without changing opendbc structs."""
import math
from opendbc.car import Bus
from opendbc.car.ford.values import FordFlags
MESSAGE = 'Lane_Assist_Data3_FD1'
SIGNALS = ('LatCtlSte_D_Stat', 'LatCtlLim_D_Stat', 'LatCtlCpblty_D_Stat', 'LaActDeny_B_Actl')
def populate_ford_pscm_status(CP, can_parsers, CS_SP, can_valid):
if CP.brand != 'ford' or not CP.flags & FordFlags.CANFD:
return
status = CS_SP.init('fordPscmStatus')
parser = can_parsers.get(Bus.pt)
if parser is None:
return
values = parser.vl.get(MESSAGE, {})
timestamps = parser.ts_nanos.get(MESSAGE, {})
if any(signal not in values or signal not in timestamps for signal in SIGNALS):
return
received = timestamps[SIGNALS[0]]
if received <= 0 or any(timestamps[signal] != received for signal in SIGNALS):
return
decoded = [values[signal] for signal in SIGNALS]
if any(not math.isfinite(value) or int(value) != value or not 0 <= value <= maximum
for value, maximum in zip(decoded, (7, 3, 3, 1), strict=True)):
return
status.canMonoTime = received
status.lateralState, status.limit, status.capability = map(int, decoded[:3])
status.denied = bool(decoded[3])
# CI.update already checked all parser validity. Reading can_valid again here
# would advance the parser's invalid-message counter a second time per tick.
# Age is evaluated by the feedback consumer using this original CAN timestamp.
status.valid = bool(can_valid)
-1
View File
@@ -63,6 +63,5 @@ def convert_carControlSP(struct: capnp.lib.capnp._DynamicStructReader) -> struct
struct_dataclass.intelligentCruiseButtonManagement = structs.IntelligentCruiseButtonManagement(
**remove_deprecated(struct_dict.get('intelligentCruiseButtonManagement', {}))
)
struct_dataclass.fordLateralPath = structs.FordLateralPath(**remove_deprecated(struct_dict.get('fordLateralPath', {})))
return struct_dataclass
@@ -1,109 +0,0 @@
import ast
from pathlib import Path
from types import SimpleNamespace
import unittest
from openpilot.cereal import custom
from openpilot.selfdrive.car.ford_pscm_status import MESSAGE, SIGNALS, populate_ford_pscm_status
from openpilot.selfdrive.car.helpers import convert_to_capnp
from opendbc.can import CANPacker, CANParser
from opendbc.car import Bus, structs
from opendbc.car.ford.values import FordFlags
class TestFordPscmStatus(unittest.TestCase):
def setUp(self):
self.cp = SimpleNamespace(brand='ford', flags=FordFlags.CANFD)
self.packer = CANPacker('ford_lincoln_base_pt')
self.parser = CANParser('ford_lincoln_base_pt', [(MESSAGE, 33), ('Yaw_Data_FD1', 100)], 0)
def update_status(self, timestamp, *, lateral_state=2, limit=0, capability=2, denied=False):
status = self.packer.make_can_msg(MESSAGE, 0, dict(zip(SIGNALS, (lateral_state, limit, capability, denied), strict=True)))
yaw = self.packer.make_can_msg('Yaw_Data_FD1', 0, {'VehYaw_W_Actl': 0.1})
self.parser.update([(timestamp, [status, yaw])])
def publish(self, *, can_valid=True):
state_sp = convert_to_capnp(structs.CarStateSP(speedLimit=13.5))
populate_ford_pscm_status(self.cp, {Bus.pt: self.parser}, state_sp, can_valid)
return state_sp
def test_decodes_status_and_preserves_receipt_time_across_other_can_messages(self):
self.update_status(1_000_000_000, limit=2, capability=1, denied=True)
original = self.publish()
self.assertEqual(original.speedLimit, 13.5)
status = original.fordPscmStatus
self.assertTrue(status.valid)
self.assertEqual(status.canMonoTime, 1_000_000_000)
self.assertEqual((status.lateralState, status.limit, status.capability, status.denied), (2, 2, 1, True))
# carStateSP may publish at 100 Hz while this 33 Hz message is absent. New
# unrelated CAN must not freshen the timestamp of an old PSCM status.
yaw = self.packer.make_can_msg('Yaw_Data_FD1', 0, {'VehYaw_W_Actl': .2})
self.parser.update([(1_080_000_000, [yaw])])
copied = self.publish().fordPscmStatus
self.assertEqual(copied.canMonoTime, 1_000_000_000)
self.assertEqual((copied.limit, copied.capability, copied.denied), (2, 1, True))
self.update_status(1_090_000_000, lateral_state=3, limit=3, capability=2)
next_state = self.publish()
with custom.CarStateSP.from_bytes(next_state.to_bytes()) as decoded:
latest = decoded.fordPscmStatus
self.assertTrue(latest.valid)
self.assertEqual(latest.canMonoTime, 1_090_000_000)
self.assertEqual((latest.lateralState, latest.limit, latest.capability, latest.denied), (3, 3, 2, False))
def test_absent_parser_unseen_message_and_invalid_can_do_not_claim_valid_status(self):
state = custom.CarStateSP.new_message()
populate_ford_pscm_status(self.cp, {}, state, True)
self.assertFalse(state.fordPscmStatus.valid)
self.assertEqual(state.fordPscmStatus.canMonoTime, 0)
self.assertFalse(self.publish().fordPscmStatus.valid)
self.update_status(1_000_000_000)
invalid = self.publish(can_valid=False).fordPscmStatus
self.assertFalse(invalid.valid)
self.assertEqual(invalid.canMonoTime, 1_000_000_000)
def test_mixed_timestamps_or_malformed_status_cannot_enable_feedback(self):
self.update_status(1_000_000_000)
self.parser.ts_nanos[MESSAGE][SIGNALS[-1]] = 990_000_000
self.assertFalse(self.publish().fordPscmStatus.valid)
self.parser.ts_nanos[MESSAGE][SIGNALS[-1]] = 1_000_000_000
for value in (float('nan'), -1, 1.5, 4):
self.parser.vl[MESSAGE]['LatCtlLim_D_Stat'] = value
self.assertFalse(self.publish().fordPscmStatus.valid)
def test_other_vehicles_and_legacy_messages_default_to_unavailable(self):
for cp in (SimpleNamespace(brand='toyota'), SimpleNamespace(brand='ford', flags=0)):
state = custom.CarStateSP.new_message(speedLimit=10.)
populate_ford_pscm_status(cp, {}, state, True)
self.assertFalse(state.fordPscmStatus.valid)
self.assertEqual(state.fordPscmStatus.canMonoTime, 0)
self.assertEqual(state.speedLimit, 10.)
# Old recordings/readers have no appended status pointer; defaults must
# remain unavailable rather than interpreting zeroed enums as fresh data.
self.assertFalse(custom.CarStateSP.new_message().fordPscmStatus.valid)
def test_actual_card_update_populates_status_after_dataclass_conversion(self):
self.update_status(1_000_000_000, limit=1)
source_path = Path(__file__).resolve().parents[1] / 'card.py'
source = ast.parse(source_path.read_text())
car_class = next(n for n in source.body if isinstance(n, ast.ClassDef) and n.name == 'Car')
method = next(n for n in car_class.body if isinstance(n, ast.FunctionDef) and n.name == 'state_update')
statements = method.body
first = next(i for i, n in enumerate(statements) if isinstance(n, ast.Assign) and ast.unparse(n.value) == 'self.CI.update(can_list)')
last = next(i for i, n in enumerate(statements) if isinstance(n, ast.Expr) and isinstance(n.value, ast.Call)
and isinstance(n.value.func, ast.Name) and n.value.func.id == 'populate_ford_pscm_status')
self.assertGreater(last, first)
code = compile(ast.Module(body=statements[first:last + 1], type_ignores=[]), str(source_path), 'exec')
ci = SimpleNamespace(update=lambda _: (SimpleNamespace(canValid=True), structs.CarStateSP(speedLimit=11.)),
can_parsers={Bus.pt: self.parser})
environment = {'self': SimpleNamespace(CP=self.cp, CI=ci), 'can_list': [], 'convert_to_capnp': convert_to_capnp,
'populate_ford_pscm_status': populate_ford_pscm_status}
exec(code, environment)
self.assertTrue(environment['CS_SP'].fordPscmStatus.valid)
self.assertEqual(environment['CS_SP'].fordPscmStatus.canMonoTime, 1_000_000_000)
self.assertEqual(environment['CS_SP'].fordPscmStatus.limit, 1)
if __name__ == '__main__':
unittest.main()
+1 -44
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import math
import time
from numbers import Number
from openpilot.cereal import log
@@ -12,11 +11,8 @@ from openpilot.common.realtime import config_realtime_process, DT_CTRL, Priority
from openpilot.common.swaglog import cloudlog
from opendbc.car.car_helpers import interfaces
from opendbc.car.ford.values import FordFlags
from opendbc.car.vehicle_model import VehicleModel
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, select_model_action_controller
from openpilot.selfdrive.controls.lib.ford_path import FordPath, FordPathController, FordPscmObserverPathController
from openpilot.selfdrive.controls.lib.latcontrol import LatControl
from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID
from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle, STEER_ANGLE_SATURATION_THRESHOLD
@@ -48,7 +44,7 @@ class Controls(ControlsExt):
self.CI = interfaces[self.CP.carFingerprint](self.CP, self.CP_SP)
self.sm = messaging.SubMaster(['lateralDelay', 'vehicleParameters', 'lateralTorqueParameters', 'modelV2', 'selfdriveState',
'extrinsicsCalibration', 'deviceMotion', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carStateSP', 'carOutput',
'extrinsicsCalibration', 'deviceMotion', 'longitudinalPlan', 'lateralManeuverPlan', 'carState', 'carOutput',
'driverMonitoringState', 'onroadEvents', 'driverAssistance'] + self.sm_services_ext,
poll='selfdriveState')
self.pm = messaging.PubMaster(['carControl', 'controlsState'] + self.pm_services_ext)
@@ -56,15 +52,6 @@ class Controls(ControlsExt):
self.steer_limited_by_safety = False
self.curvature = 0.0
self.desired_curvature = 0.0
self.ford_pscm_observer = (self.CP.brand == "ford" and self.CP.flags & FordFlags.CANFD and
self.params.get_bool("FordPscmObserver"))
self.ford_path_controller = FordPscmObserverPathController() if self.ford_pscm_observer else FordPathController()
self.ford_path_controller = select_model_action_controller(self.CP, self.params.get_bool("FordModelActionController"),
self.ford_path_controller)
self.ford_model_action = isinstance(self.ford_path_controller, FordModelActionController)
if self.CP.brand == "ford":
cloudlog.event("Ford path controller selected", controller=type(self.ford_path_controller).__name__)
self.ford_path = FordPath()
self.pose_calibrator = PoseCalibrator()
self.calibrated_pose: Pose | None = None
@@ -168,36 +155,6 @@ class Controls(ControlsExt):
actuators.curvature = float(lateral_output)
else:
actuators.steeringAngleDeg = float(lateral_output)
if self.CP.brand == "ford":
ford_model = model_v2 if self.sm.valid['modelV2'] else None
if self.ford_model_action:
reference_service = 'lateralManeuverPlan' if self.sm.valid['lateralManeuverPlan'] else 'modelV2'
self.ford_path = self.ford_path_controller.update(
ford_model, self.desired_curvature, current_curvature=self.curvature, yaw_rate=-CS.yawRate, speed=CS.vEgo, now=time.monotonic(),
measurement_time=self.sm.logMonoTime['carState'] * 1e-9,
model_time=self.sm.logMonoTime['modelV2'] * 1e-9,
reference_time=self.sm.logMonoTime[reference_service] * 1e-9,
active=CC.latActive, valid=CS.canValid and self.sm.all_checks(['carState', 'vehicleParameters', 'modelV2', reference_service]),
driver_pressed=CS.steeringPressed, driver_torque=CS.steeringTorque,
pscm_status=self.sm['carStateSP'].fordPscmStatus if self.sm.valid['carStateSP'] else None,
)
if not self.ford_path.valid:
CC.latActive = False
if self.sm.frame % 20 == 0:
cloudlog.event("Ford C2-free path tracking", model_mono_time=self.sm.logMonoTime['modelV2'],
measurement_mono_time=self.sm.logMonoTime['carState'],
reference_service=reference_service, reference_mono_time=self.sm.logMonoTime[reference_service],
measured_curvature=self.curvature,
**self.ford_path_controller.diagnostics)
elif self.ford_pscm_observer:
self.ford_path = self.ford_path_controller.update(ford_model, self.desired_curvature,
current_curvature=self.curvature, v_ego=CS.vEgo,
v_ego_raw=CS.vEgoRaw, active=CC.latActive)
else:
self.ford_path = self.ford_path_controller.update(ford_model, self.desired_curvature,
current_curvature=self.curvature, v_ego=CS.vEgo,
active=CC.latActive)
actuators.curvature = float(self.ford_path.curvature)
# Ensure no NaNs/Infs
for p in ACTUATOR_FIELDS:
attr = getattr(actuators, p)
@@ -1,181 +0,0 @@
"""Experimental Ford C2-free controller with measured-curvature C1 feedback.
Selected only by its explicit toggle. The 7 m station and one-second scale are
engineering choices. Feeding integrated heading mismatch into C1 at 1:1 is an
explicit feedback-strength choice, not an identified PSCM model or calibration.
"""
import math
import struct
import numpy as np
from opendbc.car.ford.values import CarControllerParams, FordFlags
from openpilot.selfdrive.controls.lib.ford_path import FordPath, _model_path
OFFSET_STATION_M = 7.0
HEADING_TIME_S = 1.0
CALIBRATION_APPROVED = False
def _packed(value, resolution, offset):
"""Mirror Float32 carControlSP and sign-reversed CANPacker rounding."""
value = struct.unpack("f", struct.pack("f", value))[0]
return -(math.floor((-value - offset) / resolution + 0.5) * resolution + offset)
def _finite(*values):
try:
return all(math.isfinite(value) for value in values)
except (TypeError, ValueError, OverflowError):
return False
def encode_model_action(model, desired_curvature, speed):
"""Encode y(7) and max(7, v*1s)*selected limited curvature.
Preserve the reviewed core's endpoint hold when the path ends before 7 m.
This samples the available geometry; it does not extrapolate an unseen path.
"""
if not _finite(desired_curvature, speed) or not .3 <= speed <= 55 or abs(desired_curvature) > 1:
return FordPath()
try:
path = _model_path(model)
except OverflowError:
return FordPath()
if path is None or not all(_finite(*values) for values in path):
return FordPath()
station, _, lateral, _ = path
c0 = float(np.interp(min(OFFSET_STATION_M, station[-1]), station, lateral))
c1 = max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature
return FordPath(True, c0, c1, 0., 0.) if _finite(c0, c1) else FordPath()
class ModelActionController:
"""Unquantized C0/C1 slew positions and one C1 feedback correction.
Feedback integrates requested minus measured curvature over traveled distance.
Freshness, measurement cadence and driver/PSCM arbitration belong to the caller.
"""
__slots__ = ('c0', 'c1', 'correction')
def __init__(self):
self.reset()
def reset(self):
self.c0 = self.c1 = self.correction = 0.
def update(self, model, desired_curvature, *, current_curvature, speed, dt, active=True, valid=True,
feedback_dt=None, feedback_enabled=True, pscm_limited=False):
feedback_dt = dt if feedback_dt is None else feedback_dt
if (not active or not valid or not _finite(dt, feedback_dt, current_curvature) or not .002 <= dt <= .1
or not 0. <= feedback_dt <= .15 or abs(current_curvature) > 1.):
self.reset()
return FordPath()
target = encode_model_action(model, desired_curvature, speed)
if not target.valid:
self.reset()
return FordPath()
c0 = float(np.clip(target.path_offset, -5.11, 5.11))
base_c1 = float(np.clip(target.path_angle, -.5, .5))
lower = max(-.5, self.c1-.5*dt)
upper = min(.5, self.c1+.5*dt)
if not feedback_enabled:
self.correction = 0.
else:
increment = (desired_curvature-current_curvature)*speed*feedback_dt
# LimitReached inhibits only extra demand in the measured turn direction.
# Opposing correction and changes to the model request remain available.
direction = current_curvature if current_curvature else self.c1
if pscm_limited and increment*direction > 0.:
# An old opposing correction may return to zero; don't trap it below
# the base request just because the PSCM now reports a limit.
increment = float(np.clip(increment, min(-self.correction, 0.), max(-self.correction, 0.)))
# Integrate only as far as this cycle's amplitude/slew envelope permits.
# If the base moved outside that envelope, allow increments toward it;
# never rewrite existing correction merely because the base changed.
request = base_c1+self.correction
self.correction += float(np.clip(increment, min(lower-request, 0.), max(upper-request, 0.)))
self.c0 += float(np.clip(c0-self.c0, -4.*dt, 4.*dt))
c1 = float(np.clip(base_c1+self.correction, -.5, .5))
self.c1 += float(np.clip(c1-self.c1, -.5*dt, .5*dt))
return FordPath(True, _packed(self.c0, .01, -5.12), _packed(self.c1, .0005, -.5), 0., 0.)
class FordModelActionController:
"""Input adapter for the opt-in selected-action controller.
controlsd owns upstream selection/limiting and service health. This adapter
checks ages and clock order, then supplies elapsed time to the three-state
core. Feedback advances once per fresh steering measurement; repeated samples
can still advance output slew. Raw model geometry is checked on every cycle.
CAN yaw remains a health gate, not the feedback measurement. Driver override
clears the correction. Fresh PSCM limits only inhibit outward integration;
neither a limit nor a repeated measurement freezes the model request.
"""
def __init__(self):
self.core = ModelActionController()
self.reset()
def reset(self, status='inactive'):
self.core.reset()
self.last_time = self.last_measurement_time = self.last_model_time = None
self.diagnostics = {'status': status, 'hypothesis': 'model-action-c1-feedback-v1',
'calibration_approved': CALIBRATION_APPROVED, 'command': (0., 0., 0., 0.)}
def update(self, model, desired_curvature, *, current_curvature, yaw_rate, speed, now, measurement_time, model_time,
reference_time, active, valid=True, driver_pressed=False, driver_torque=0., pscm_status=None):
reason = None
if not active:
reason = 'inactive'
elif not valid:
reason = 'invalid_service'
elif not _finite(desired_curvature, current_curvature, yaw_rate, speed, now, measurement_time, model_time, reference_time):
reason = 'nonfinite'
elif not all(-.005 <= now - timestamp <= .15 for timestamp in (measurement_time, model_time, reference_time)):
reason = 'stale_input'
elif not .3 <= speed <= 55 or abs(yaw_rate) > 3 or abs(desired_curvature) > 1 or abs(current_curvature) > 1:
reason = 'input_range'
if reason is not None:
self.reset(reason)
return FordPath()
dt = .01 if self.last_time is None else now - self.last_time
feedback_dt = 0. if self.last_measurement_time is None else measurement_time-self.last_measurement_time
if not .002 <= dt <= .1 or not 0. <= feedback_dt <= .15 or (
self.last_model_time is not None and model_time < self.last_model_time
):
self.reset('timing_reset')
return FordPath()
status_fresh = (pscm_status is not None and pscm_status.valid and pscm_status.canMonoTime > 0
and -.005 <= now-pscm_status.canMonoTime*1e-9 <= .15)
pscm_limited = bool(status_fresh and pscm_status.limit == 2)
driver_override = bool(driver_pressed or not _finite(driver_torque)
or abs(driver_torque) > CarControllerParams.STEER_DRIVER_ALLOWANCE
or (status_fresh and pscm_status.limit == 3))
feedback_enabled = not (driver_override or (status_fresh and (pscm_status.denied or pscm_status.lateralState != 2)))
command = self.core.update(model, desired_curvature, current_curvature=current_curvature, speed=speed, dt=dt,
feedback_dt=feedback_dt, feedback_enabled=feedback_enabled, pscm_limited=pscm_limited)
if not command.valid:
self.reset('invalid_path')
return command
self.last_time, self.last_measurement_time, self.last_model_time = now, measurement_time, model_time
self.diagnostics = {'status': 'active', 'hypothesis': 'model-action-c1-feedback-v1',
'calibration_approved': CALIBRATION_APPROVED, 'desired_curvature': desired_curvature,
'model_age': now - model_time, 'measurement_age': now - measurement_time, 'reference_age': now - reference_time,
'dt': dt, 'offset_request': self.core.c0, 'heading_request': self.core.c1,
'curvature_error': desired_curvature-current_curvature, 'feedback_dt': feedback_dt,
'heading_feedforward': float(np.clip(max(OFFSET_STATION_M, speed*HEADING_TIME_S)*desired_curvature, -.5, .5)),
'heading_correction': self.core.correction, 'feedback_enabled': feedback_enabled,
'driver_override': driver_override, 'pscm_limited': pscm_limited, 'pscm_status_fresh': bool(status_fresh),
'command': (command.path_offset, command.path_angle, 0., 0.)}
return command
def select_model_action_controller(CP, enabled, previous_controller):
"""The separate default-off toggle takes priority on the CAN FD Lightning."""
compatible = CP.brand == 'ford' and CP.flags & FordFlags.CANFD and CP.carFingerprint == 'FORD_F_150_LIGHTNING_MK1'
if enabled and compatible:
return FordModelActionController()
return previous_controller
@@ -1,368 +0,0 @@
from collections import deque
from dataclasses import dataclass
import math
import numpy as np
from opendbc.car.ford.values import CarControllerParams
DBC_OFFSET = (-5.12, 5.11)
DBC_ANGLE = (-0.5, 0.5235)
DBC_CURVATURE = (-0.02, 0.02)
DBC_CURVATURE_RATE = (-0.001024, 0.001023)
DBC_OFFSET_RESOLUTION = 0.01
DBC_ANGLE_RESOLUTION = 0.0005
DBC_CURVATURE_RESOLUTION = 0.00002
DBC_CURVATURE_RATE_RESOLUTION = 0.000001
_PATH_MIN_LOOKAHEAD = 7.0
_POSE_PREDICTION_TIME = 0.1
_POSE_BLEND_CURVATURE = (0.006, 0.012)
_PATH_OFFSET_RATE = 4.0
_PATH_ANGLE_RATE = 1.0
_PSCM_DT = 0.004
_PSCM_C0_RATE = 1.5
_PSCM_C1_RATE = 0.100006103515625
_PSCM_C2_RATE = 0.0030059814453125
_PSCM_SPEED_KPH = (0.0, 15.0, 40.0, 70.0, 100.0, 150.0, 200.0, 250.0)
_PSCM_SPEED_GAIN = (32.0, 32.0, 32.0, 30.0, 30.0, 24.0, 12.0, 0.0)
_PSCM_C0_EFFECTIVE_LIMIT = 1.0
_PSCM_C1_EFFECTIVE_LIMIT = 0.349609375 / 10.0
@dataclass(frozen=True)
class FordPath:
valid: bool = False
path_offset: float = 0.0
path_angle: float = 0.0
curvature: float = 0.0
curvature_rate: float = 0.0
@dataclass(frozen=True)
class FordPscmState:
path_offset: float = 0.0
path_angle: float = 0.0
curvature: float = 0.0
@dataclass(frozen=True)
class FordModelPose:
path_offset: float
path_angle: float
offset_horizon: float
curvature_demand: float
forward_angle: float
def _finite(value: float) -> float:
return float(value) if math.isfinite(value) else 0.0
def _sample(distance: float, distances: list[float], values: list[float]) -> float:
return float(np.interp(distance, distances, values))
def _blend_share(demand: float) -> float:
lower, upper = _POSE_BLEND_CURVATURE
return float(np.clip((demand - lower) / (upper - lower), 0.0, 1.0))
def _model_path(model) -> tuple[list[float], list[float], list[float], list[float]] | None:
try:
x = [float(value) for value in model.position.x]
y = [float(value) for value in model.position.y]
heading = [float(value) for value in model.orientation.z]
except (AttributeError, TypeError, ValueError):
return None
if len(x) < 2 or len(x) != len(y) or len(x) != len(heading):
return None
if not all(math.isfinite(value) for values in (x, y, heading) for value in values):
return None
distance = [0.0]
for i in range(1, len(x)):
distance.append(distance[-1] + math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]))
if distance[-1] <= 0.0:
return None
unwrapped_heading = [heading[0]]
for value in heading[1:]:
delta = (value - unwrapped_heading[-1] + math.pi) % (2.0 * math.pi) - math.pi
unwrapped_heading.append(unwrapped_heading[-1] + delta)
return distance, x, y, unwrapped_heading
def _predicted_pose(distance: float, current_curvature: float,
curvature_delta: float) -> tuple[float, float, float]:
curvature = current_curvature + 0.5 * curvature_delta
heading = curvature * distance
if abs(curvature) < 1e-9:
return distance, 0.0, 0.0
return math.sin(heading) / curvature, (1.0 - math.cos(heading)) / curvature, heading
def _relative_pose(target_distance: float, path: tuple[list[float], list[float], list[float], list[float]],
vehicle_pose: tuple[float, float, float]) -> tuple[float, float]:
distance, x, y, heading = path
vehicle_x, vehicle_y, vehicle_heading = vehicle_pose
dx = _sample(target_distance, distance, x) - vehicle_x
dy = _sample(target_distance, distance, y) - vehicle_y
cosine = math.cos(vehicle_heading)
sine = math.sin(vehicle_heading)
offset = -sine * dx + cosine * dy
angle = math.atan2(math.sin(_sample(target_distance, distance, heading) - vehicle_heading),
math.cos(_sample(target_distance, distance, heading) - vehicle_heading))
return offset, angle
def _path_pose(target_distance: float,
path: tuple[list[float], list[float], list[float], list[float]]) -> tuple[float, float, float]:
distance, x, y, heading = path
return (_sample(target_distance, distance, x), _sample(target_distance, distance, y),
_sample(target_distance, distance, heading))
def _bounded_feedback(feedforward: float, feedback: float, resolution: float, zero_path_limit: float) -> float:
quantization_threshold = 0.5 * resolution
limit = max(abs(feedforward) - resolution, 0.0) if abs(feedforward) >= quantization_threshold else zero_path_limit
return float(np.clip(feedback, -limit, limit))
def _model_pose(path: tuple[list[float], list[float], list[float], list[float]],
current_curvature: float, curvature_delta: float, v_ego: float) -> FordModelPose:
distance, _, _, _ = path
advance = min(v_ego * _POSE_PREDICTION_TIME, distance[-1])
offset_horizon = min(_PATH_MIN_LOOKAHEAD, distance[-1] - advance)
angle_horizon = min(max(v_ego, _PATH_MIN_LOOKAHEAD), distance[-1] - advance)
# Keep the model's remaining path as feedforward. Measured vehicle motion is
# a separate, short delay-aligned correction, so catching the requested
# curvature cannot erase a turn that is still present in the model path.
model_pose = _path_pose(advance, path)
model_offset, _ = _relative_pose(advance + offset_horizon, path, model_pose)
_, model_angle = _relative_pose(advance + angle_horizon, path, model_pose)
vehicle_pose = _predicted_pose(advance, current_curvature, curvature_delta)
feedback_offset, feedback_angle = _relative_pose(advance, path, vehicle_pose)
gentle_curvature = _POSE_BLEND_CURVATURE[0]
feedback_offset = _bounded_feedback(model_offset, feedback_offset, DBC_OFFSET_RESOLUTION,
0.5 * gentle_curvature * advance ** 2)
feedback_angle = _bounded_feedback(model_angle, feedback_angle, DBC_ANGLE_RESOLUTION,
gentle_curvature * advance)
offset_curvature = 2.0 * model_offset / max(offset_horizon, 1e-3) ** 2
angle_curvature = model_angle / max(angle_horizon, 1e-3)
return FordModelPose(model_offset + feedback_offset, model_angle + feedback_angle, offset_horizon,
max(abs(offset_curvature), abs(angle_curvature)), model_angle)
def _encode_pose(pose: FordModelPose, pose_share: float, curvature: float) -> FordPath:
path_offset = pose_share * pose.path_offset
path_angle = pose_share * pose.path_angle
if abs(path_offset) < 0.5 * DBC_OFFSET_RESOLUTION:
path_offset = 0.0
if abs(path_angle) < 0.5 * DBC_ANGLE_RESOLUTION:
path_angle = 0.0
limited_path_angle = float(np.clip(path_angle, *DBC_ANGLE))
path_offset += (path_angle - limited_path_angle) * pose.offset_horizon
return FordPath(
valid=True,
path_offset=float(np.clip(path_offset, *DBC_OFFSET)),
path_angle=limited_path_angle,
curvature=float(np.clip(curvature, *DBC_CURVATURE)),
curvature_rate=0.0,
)
def _encode_path(path: tuple[list[float], list[float], list[float], list[float]], desired_curvature: float,
current_curvature: float, curvature_delta: float, v_ego: float) -> FordPath:
pose = _model_pose(path, current_curvature, curvature_delta, v_ego)
pose_share = _blend_share(max(pose.curvature_demand, abs(desired_curvature)))
# Match upstream's C2-only normal driving, then continuously transfer the
# command to the model pose for larger maneuvers. An opposing/finished model
# path must unload sticky C2 and retain the fast pose needed to unwind it.
c2_opposes_path = desired_curvature != 0.0 and desired_curvature * pose.forward_angle <= 0.0
if c2_opposes_path:
pose_share = 1.0
curvature = 0.0
else:
curvature = desired_curvature * (1.0 - pose_share)
return _encode_pose(pose, pose_share, curvature)
class FordPathController:
"""Blend normal C2 following into the model's forward C0/C1 pose."""
def __init__(self, dt: float = 0.01):
self.dt = dt
self._last_path = FordPath(valid=True)
self._curvature_history = deque(maxlen=max(round(_POSE_PREDICTION_TIME / dt) + 1, 2))
def _limit(self, target: FordPath) -> FordPath:
offset_delta = target.path_offset - self._last_path.path_offset
angle_delta = target.path_angle - self._last_path.path_angle
scale = min(
1.0,
_PATH_OFFSET_RATE * self.dt / abs(offset_delta) if offset_delta else 1.0,
_PATH_ANGLE_RATE * self.dt / abs(angle_delta) if angle_delta else 1.0,
)
self._last_path = FordPath(
True,
self._last_path.path_offset + scale * offset_delta,
self._last_path.path_angle + scale * angle_delta,
self._last_path.curvature + scale * (target.curvature - self._last_path.curvature),
0.0,
)
return self._last_path
def update(self, model, desired_curvature: float, *, current_curvature: float = 0.0,
v_ego: float = 0.0, active: bool = True) -> FordPath:
if not active:
self._last_path = FordPath(valid=True)
self._curvature_history.clear()
return FordPath()
current_curvature = _finite(current_curvature)
self._curvature_history.append(current_curvature)
curvature_delta = (current_curvature - self._curvature_history[0]
if len(self._curvature_history) == self._curvature_history.maxlen else 0.0)
path = _model_path(model) if model is not None else None
if path is None:
return self._limit(FordPath(valid=True))
return self._limit(_encode_path(path, _finite(desired_curvature), current_curvature, curvature_delta,
max(_finite(v_ego), 0.0)))
def _pscm_slew(value: float, target: float, rate: float, ticks: int) -> float:
step = rate * _PSCM_DT * ticks
return float(np.clip(target, value - step, value + step))
def _pscm_speed_gain(v_ego: float) -> float:
return float(np.interp(max(v_ego, 0.0) * 3.6, _PSCM_SPEED_KPH, _PSCM_SPEED_GAIN))
def _wire_path(path: FordPath) -> FordPath:
return FordPath(
valid=path.valid,
path_offset=round(path.path_offset / DBC_OFFSET_RESOLUTION) * DBC_OFFSET_RESOLUTION,
path_angle=round(path.path_angle / DBC_ANGLE_RESOLUTION) * DBC_ANGLE_RESOLUTION,
curvature=round(path.curvature / DBC_CURVATURE_RESOLUTION) * DBC_CURVATURE_RESOLUTION,
curvature_rate=round(path.curvature_rate / DBC_CURVATURE_RATE_RESOLUTION) * DBC_CURVATURE_RATE_RESOLUTION,
)
def _pscm_contributions(state: FordPscmState, v_ego: float) -> tuple[float, float, float]:
gain = _pscm_speed_gain(v_ego)
return (
float(np.clip(0.5 * gain * state.path_offset, -0.5 * gain, 0.5 * gain)),
float(np.clip(10.0 * gain * state.path_angle, -0.349609375 * gain, 0.349609375 * gain)),
float(np.clip(0.30078125 * gain * state.curvature * v_ego ** 2, -0.5 * gain, 0.5 * gain)),
)
class FordPscmObserver:
"""Mirror the firmware's held-command coefficient states at its 250 Hz step."""
def __init__(self):
self.state = FordPscmState()
self.command = FordPath(valid=True)
self._phase = 0.0
def reset(self) -> None:
self.state = FordPscmState()
self.command = FordPath(valid=True)
self._phase = 0.0
def advance(self, elapsed: float) -> None:
self._phase += max(elapsed, 0.0)
ticks = int((self._phase + 1e-12) / _PSCM_DT)
self._phase -= ticks * _PSCM_DT
if ticks == 0:
return
self.state = FordPscmState(
_pscm_slew(self.state.path_offset, self.command.path_offset, _PSCM_C0_RATE, ticks),
_pscm_slew(self.state.path_angle, self.command.path_angle, _PSCM_C1_RATE, ticks),
_pscm_slew(self.state.curvature, self.command.curvature + 10.0 * self.command.curvature_rate,
_PSCM_C2_RATE, ticks),
)
def set_command(self, command: FordPath) -> None:
self.command = _wire_path(command)
class FordPscmObserverPathController:
"""Compensate model-path commands for the PSCM coefficient state it still carries."""
def __init__(self, dt: float = 0.01):
self.dt = dt
self._last_path = FordPath(valid=True)
self._curvature_history = deque(maxlen=max(round(_POSE_PREDICTION_TIME / dt) + 1, 2))
self.observer = FordPscmObserver()
self._sent_c2 = 0.0
def _reset(self) -> None:
self._last_path = FordPath(valid=True)
self._curvature_history.clear()
self.observer.reset()
self._sent_c2 = 0.0
def _command_for_state(self, target: FordPath, v_ego: float) -> FordPath:
# The target describes the desired fully-settled PSCM contribution. C0 keeps
# the remaining C1-saturated residual. C1 supplies the primary contribution
# that the known slow C2 state does not yet provide, without a guessed gain.
target_state = FordPscmState(target.path_offset, target.path_angle, target.curvature)
target_contribution = sum(_pscm_contributions(target_state, v_ego))
_, _, observed_c2 = _pscm_contributions(self.observer.state, v_ego)
gain = _pscm_speed_gain(v_ego)
required_fast = target_contribution - observed_c2
c1_contribution = float(np.clip(required_fast, -0.349609375 * gain, 0.349609375 * gain))
c0_contribution = required_fast - c1_contribution
path_offset = c0_contribution / (0.5 * gain) if gain > 0.0 else 0.0
path_angle = c1_contribution / (10.0 * gain) if gain > 0.0 else 0.0
return FordPath(
valid=True,
path_offset=float(np.clip(path_offset, -_PSCM_C0_EFFECTIVE_LIMIT, _PSCM_C0_EFFECTIVE_LIMIT)),
path_angle=float(np.clip(path_angle, -_PSCM_C1_EFFECTIVE_LIMIT, _PSCM_C1_EFFECTIVE_LIMIT)),
curvature=target.curvature,
curvature_rate=target.curvature_rate,
)
def _limit(self, target: FordPath, v_ego_raw: float) -> FordPath:
path_offset = float(np.clip(target.path_offset,
self._last_path.path_offset - _PATH_OFFSET_RATE * self.dt,
self._last_path.path_offset + _PATH_OFFSET_RATE * self.dt))
path_angle = float(np.clip(target.path_angle,
self._last_path.path_angle - _PATH_ANGLE_RATE * self.dt,
self._last_path.path_angle + _PATH_ANGLE_RATE * self.dt))
curvature = CarControllerParams.CURVATURE_LIMITS.apply_limits(
target.curvature, self._sent_c2, v_ego_raw, 0.0, True, CarControllerParams.LMC2_STEP,
)
self._sent_c2 = curvature
self._last_path = FordPath(True, path_offset, path_angle, curvature, target.curvature_rate)
self.observer.set_command(self._last_path)
return self._last_path
def update(self, model, desired_curvature: float, *, current_curvature: float = 0.0,
v_ego: float = 0.0, v_ego_raw: float = 0.0, active: bool = True) -> FordPath:
if not active:
self._reset()
return FordPath()
self.observer.advance(self.dt)
current_curvature = _finite(current_curvature)
self._curvature_history.append(current_curvature)
curvature_delta = (current_curvature - self._curvature_history[0]
if len(self._curvature_history) == self._curvature_history.maxlen else 0.0)
path = _model_path(model) if model is not None else None
if path is None:
target = FordPath(valid=True)
else:
target = _encode_path(path, _finite(desired_curvature), current_curvature, curvature_delta,
max(_finite(v_ego), 0.0))
v_ego_raw = max(_finite(v_ego_raw), 0.0)
command = self._command_for_state(target, v_ego_raw)
return self._limit(command, v_ego_raw)
@@ -1,229 +0,0 @@
{
"description": "Curvature-driven C0 and full-heading C1 command regression; does not predict counterfactual wheel response. Contains geometry and control signals only, no GPS.",
"fixture_sha256": "12782ac1b0d0637945f729a46ad03af16cd58188872b6a65f104e32c4db70e9b",
"episodes": [
{
"name": "left_large",
"route": "84865544361f55cb_00000077--4b55791ce6",
"range_seconds": [
809.5,
815.0
],
"evidence_seconds": [
812.1,
814.0
],
"samples": 532
},
{
"name": "right_large",
"route": "84865544361f55cb_00000077--4b55791ce6",
"range_seconds": [
866.5,
872.0
],
"evidence_seconds": [
869.5,
871.0
],
"samples": 547
},
{
"name": "left_very_large",
"route": "84865544361f55cb_00000077--4b55791ce6",
"range_seconds": [
880.0,
884.0
],
"evidence_seconds": [
882.9,
883.32
],
"samples": 397
},
{
"name": "right_plateau",
"route": "84865544361f55cb_00000077--4b55791ce6",
"range_seconds": [
964.0,
970.0
],
"evidence_seconds": [
967.2,
968.93
],
"samples": 583
},
{
"name": "oscillation",
"route": "84865544361f55cb_00000078--349f5b8695",
"range_seconds": [
29.0,
37.0
],
"evidence_seconds": [
32.5,
36.1
],
"samples": 795
},
{
"name": "weak_first",
"route": "84865544361f55cb_0000007a--5a95fc717e",
"range_seconds": [
90.5,
95.2
],
"evidence_seconds": [
93.5,
95.08
],
"samples": 467
},
{
"name": "weak_second",
"route": "84865544361f55cb_0000007a--5a95fc717e",
"range_seconds": [
119.5,
125.3
],
"evidence_seconds": [
122.5,
125.2
],
"samples": 576
}
],
"sources": {
"84865544361f55cb_00000077--4b55791ce6": [
{
"name": "84865544361f55cb_00000077--4b55791ce6--0--rlog.zst",
"bytes": 10342855,
"sha256": "2f072eff3076f4d32dbc1a077f24fc85818abeafd13a4b970b1ba558a0d2ca52"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--1--rlog.zst",
"bytes": 11688902,
"sha256": "bbbbf7fc79b1c7133b83678a5202ac41cd8b3dcdd0bb358843d43fdb38f25e1e"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--2--rlog.zst",
"bytes": 11875525,
"sha256": "18d7e0224a61d068aeeef0e176da071f5e67d77fcb7ebaa7b5854756d4438add"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--3--rlog.zst",
"bytes": 12489313,
"sha256": "7869f95c87849018df07c680e0584145a31c407608f96f2fc77f35b729f02d2c"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--4--rlog.zst",
"bytes": 12301118,
"sha256": "2c2125fb2320b9bc6620fc586cedb6558b33e8475d0cce3fb361db4eea6a1c28"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--5--rlog.zst",
"bytes": 12976655,
"sha256": "b18c448786daf46cfa05bf0352396ce5851a09603d34ee5b989e096da5ee5180"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--6--rlog.zst",
"bytes": 13223857,
"sha256": "8c08b8d47ebca38c70aa94a1cdda25443ed3be94727bb487406b518471c88dd1"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--7--rlog.zst",
"bytes": 13043701,
"sha256": "638948d7e5853046773f82df8a531c518c42883c77f060e61ff683ccb59d52af"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--8--rlog.zst",
"bytes": 12569024,
"sha256": "41e5c83d2205964889579cf24967712dd0340da5f30f213409f2b1e04e6eb78a"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--9--rlog.zst",
"bytes": 11926213,
"sha256": "6034a90e817424c02755c2c0d9bdbad088c4286edfb887d9f2acbd60d7818da7"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--10--rlog.zst",
"bytes": 13010961,
"sha256": "23958a1ac8277977952c73e889fbfd9245bc2cf82b3af58233c245dbf1e76545"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--11--rlog.zst",
"bytes": 13204208,
"sha256": "f78d65b7b5927a8570f52d765f9af45431f8ab72260987f718321ad1b03c70af"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--12--rlog.zst",
"bytes": 12562994,
"sha256": "485621d1cf71605fccc8c679cb146b1f0954078849db7c74b1b65b166d741025"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--13--rlog.zst",
"bytes": 12610836,
"sha256": "0b4b0c01caae39a7dc4ff2219ab168029ea12c43b1966b8c204972743627c24b"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--14--rlog.zst",
"bytes": 13114068,
"sha256": "f18769800bd08c7614280916b50ec0274eca9b4765ad26d6549b72912321dac0"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--15--rlog.zst",
"bytes": 13113162,
"sha256": "abf0adc702db6f5fdd78a145709504c7061255bc9b9a61329a575707d5bd2f74"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--16--rlog.zst",
"bytes": 13029294,
"sha256": "a76ed74b889bda60e2d929123186df83653591d1e058fbd1497551fbbf23941e"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--17--rlog.zst",
"bytes": 12429846,
"sha256": "51d3aff2afa1a7ce3c5372a499b14394f22ac730e952273a69496c65ef726ed2"
},
{
"name": "84865544361f55cb_00000077--4b55791ce6--18--rlog.zst",
"bytes": 9241845,
"sha256": "2a1646227f9cdb1ab7eaf79fa653b7f4443a629a703577ff6c38a715dbced3a4"
}
],
"84865544361f55cb_00000078--349f5b8695": [
{
"name": "84865544361f55cb_00000078--349f5b8695--0--rlog.zst",
"bytes": 10919702,
"sha256": "2d35f6c9ac9b09f8b86b3fe2fbe864e4af0d50c411e3dd56572b5aa113bf1973"
},
{
"name": "84865544361f55cb_00000078--349f5b8695--1--rlog.zst",
"bytes": 10600112,
"sha256": "6cabb48ea0caeb2fddd58be35b5c7e7c42faf87fc01991c4603573bffe33eaeb"
}
],
"84865544361f55cb_0000007a--5a95fc717e": [
{
"name": "84865544361f55cb_0000007a--5a95fc717e--0--rlog.zst",
"bytes": 10841486,
"sha256": "bfc9e3308e5241e76cba57f2441043710fe8b18ade314221ee5e423f640c211a"
},
{
"name": "84865544361f55cb_0000007a--5a95fc717e--1--rlog.zst",
"bytes": 12567837,
"sha256": "ab9eb05c5805e286a6ec639bbbbc1cf086bfcf1b440801ad000713db95dfa7fc"
},
{
"name": "84865544361f55cb_0000007a--5a95fc717e--2--rlog.zst",
"bytes": 11015857,
"sha256": "af4f87f39be37f0a5b23b58de50c2ed8801bdbda6544cd6cd292525fc3bd4fb3"
}
]
},
"pairing": "controlsState cycle time; causal carState speed/yaw/pressed; exact consumed model timestamp and geometry; nearest same-cycle carControl and carControlSP within 5ms.",
"yaw_rate": "Negative carState.yawRate, matching the model/control curvature coordinate sign; no wheel-to-curvature conversion.",
"desired_curvature": "Exact controlsState.desiredCurvature from the matching controlsState cycle. This is the post-selection, post-limiting request consumed by controlsd; it is not a wheel-angle-to-curvature fit.",
"reference_time": "Exact consumed modelV2 publication time, in the same relative seconds as each episode. The extraction cache does not retain consumed lateralManeuverPlan timestamps or validity; model time is an explicit replay assumption and cannot verify alternate-reference freshness."
}
@@ -1,67 +0,0 @@
{
"description": "Real route80 turn-command regressions. Signal-only fixture; no GPS. Counterfactual commands do not predict physical vehicle response.",
"route": "84865544361f55cb_00000080--1643deea7e",
"source_commit": "98662df401217a00ec9fc8e73b16857b6c220150",
"frozen_v3_controller_sha256": "576f4ec6f2dbc93f7e6c93a69839f69447eb5a0c2f834bd48b24f84a163dc2eb",
"fixture_sha256": "c1460e2cf1d3fd52b1a036d923fec7835a7d361126ee0c2decbc3f101ee6653c",
"episodes": [
{
"name": "under_333_339",
"range_seconds": [
331.5,
339.0
],
"evidence_seconds": [
333.0,
339.0
],
"samples": 745
},
{
"name": "over_417_420",
"range_seconds": [
415.5,
420.0
],
"evidence_seconds": [
417.0,
420.0
],
"samples": 447
},
{
"name": "under_430_435",
"range_seconds": [
428.5,
435.0
],
"evidence_seconds": [
430.0,
435.0
],
"samples": 646
}
],
"sources": [
{
"name": "84865544361f55cb_00000080--1643deea7e--5--rlog.zst",
"bytes": 12531711,
"sha256": "059482830794cb0eabe6069b75a9610b900bf2a93d7a6624f53c575cef997157"
},
{
"name": "84865544361f55cb_00000080--1643deea7e--6--rlog.zst",
"bytes": 12560505,
"sha256": "147276789f5b14913adc4cd16db18f3d4bd27ce8497c9ff96fdf0315c219339f"
},
{
"name": "84865544361f55cb_00000080--1643deea7e--7--rlog.zst",
"bytes": 12660797,
"sha256": "b311b6ace75819db52b9618154d68c7d12e2751d5046b6d174adb89ef87a223c"
}
],
"pairing": "Exact controlsState desiredCurvature and consumed model publication timestamp; causal carState speed, negative CAN yaw, and steeringPressed; nearest same-cycle carControl/carControlSP within 5 ms.",
"reference_time": "Consumed modelV2 publication time. Controller audit confirms route80 used modelV2 as reference throughout.",
"preroll": "Each episode starts from reset 1.5 s before evidence; v3_replay stores those exact cold-start commands and gates, while recorded stores original live path fields.",
"benchmark_clean": "Existing route80 benchmark mask: whole interval request minus 0.5 s through response (0.2 s) plus 0.25 s active, unpressed, valid, fresh, and speed >= 2 m/s.",
"expected_common_c1": "Independent shadow: clip(desiredCurvature * max(7 m, vEgo * 1 s), +/-0.5 rad), independently slewed at 0.5 rad/s and packed to Float32/sign-reversed CAN semantics. No subtraction of measured curvature."
}
@@ -1,13 +0,0 @@
{
"description": "PSCM status and raw driver-torque overlay for the existing three route80 request windows. No GPS. No counterfactual vehicle response.",
"fixture_sha256": "a9defdc5abdf26724358d606beb16becbdf30faa972974d49b179a9e004d7629",
"base_fixture": "ford_curvature_heading_route80.npz",
"base_fixture_sha256": "c1460e2cf1d3fd52b1a036d923fec7835a7d361126ee0c2decbc3f101ee6653c",
"source_route": "84865544361f55cb_00000080--1643deea7e",
"source_commit": "98662df401217a00ec9fc8e73b16857b6c220150",
"samples": 1838,
"source_cache_sha256": "1cd3e0c00805869ace1c5954dc682644f36f5eddb71785b69e4cb9da40f7f04f",
"pairing": "Latest actual bus-0 EPS 972 frame at or before each controlsState cycle; raw steering torque from the exact causal carState used by the base fixture.",
"timestamp_policy": "Actual CAN event logMonoTime in route-relative seconds, not the benchmark response-shifted status. The old route predates the new carStateSP status telemetry; source CAN timestamps are an explicit replay approximation.",
"validity": "Replay validity uses the paired carState valid and canValid values; enum validity, availability and age are checked by the production feedback controller."
}
@@ -1,40 +0,0 @@
{
"description": "Signal-only v6 turn-exit recovery regression; no location, device identity, or predicted new vehicle response.",
"recorded_controller_revision": "61dac4977bf9c36504398e8a4959dfed79cf6f05",
"baseline_revision": "61dac4977bf9c36504398e8a4959dfed79cf6f05",
"response_delay": 0.20000000298023224,
"samples": 9134,
"models": 1843,
"fixture_sha256": "41d5e3efcee03a9e02fcaf7bf456c050c6a671a5b7f7fc9706ddf4d27bad71b8",
"windows": [
{
"name": "overturn_then_underturn",
"range_s": [
19.99615067150053,
29.48070058550053
],
"samples": 521
},
{
"name": "well_tracked_curve_a",
"range_s": [
56.19474309950053,
63.68808603150053
],
"samples": 729
},
{
"name": "well_tracked_curve_b",
"range_s": [
101.19780691350051,
116.33885445450052
],
"samples": 810
}
],
"selection": "One previously identified overturn-then-underturn event and two previously reported well-tracked curves; selected before recovery implementation.",
"mask": "Whole t-0.5 through t+0.65 interval active, valid, fresh, unpressed, raw driver torque magnitude <=1 Nm; requested |curvature|*speed\u00b2 >=.5 m/s\u00b2.",
"timing": "Exact consumed model publication; causal CAN/PSCM at estimated control computation time. Subtract observed median computation-to-publication delay; unsampled tick timing remains approximate.",
"context": "At least 20 seconds prior context or the available start, extended before the latest observed reset. Overlapping episodes are merged.",
"coordinates": "Times are local elapsed seconds; models contain only relative position.x/y and orientation.z arrays."
}
@@ -1,247 +0,0 @@
{
"description": "Signal-only historical fallback evidence and frozen-v5 comparison; no GPS or inferred counterfactual vehicle response.",
"route": "route83",
"recorded_commit": "79a4caa1f6b71488949108aee9ae6ae6566347b1",
"fixture_sha256": "d00312c430ace47000c05b8284ee8d56df56ec24bb17a9ea8f4dce83133527c3",
"samples": 11744,
"model_count": 2367,
"source_cache_sha256": "53d786aff2e0b6338e1991320145305fda3101f7b76e50bd2929adbcaea95b28",
"response_delay": 0.20000000298023224,
"episodes": [
[
1861.2933736250002,
1874.756970279
],
[
1878.07372132,
1892.07372132
],
[
1950.874232409,
1964.874232409
],
[
2440.9020600930003,
2456.964158177
],
[
2580.722658577,
2611.364366768
],
[
2734.478264791,
2764.574374172
]
],
"windows": [
{
"name": "successful_large_early",
"role": "authority_target",
"range_s": [
1866.722720383,
1874.756970279
],
"samples": 426,
"substantial_demand_required": true,
"recorded_can_ratio_02s_median": 1.0233371460413845,
"published_median_abs_c0_c1": [
1.6002928018569946,
0.2796146124601364
],
"send_clamped_median_abs_c0_c1": [
1.6002928018569946,
0.2796146124601364
],
"phase_samples": {
"phase_turn_in": 15,
"phase_held": 122,
"phase_release": 402,
"phase_reversal": 0
}
},
{
"name": "centering_reversal_positive_to_negative",
"role": "reversal",
"range_s": [
1888.07372132,
1892.07372132
],
"samples": 396,
"substantial_demand_required": false,
"recorded_can_ratio_02s_median": null,
"published_median_abs_c0_c1": [
0.0,
0.0
],
"send_clamped_median_abs_c0_c1": [
0.0,
0.0
],
"phase_samples": {
"phase_turn_in": 283,
"phase_held": 48,
"phase_release": 104,
"phase_reversal": 21
}
},
{
"name": "centering_reversal_negative_to_positive",
"role": "reversal",
"range_s": [
1960.874232409,
1964.874232409
],
"samples": 397,
"substantial_demand_required": false,
"recorded_can_ratio_02s_median": null,
"published_median_abs_c0_c1": [
0.0,
0.0
],
"send_clamped_median_abs_c0_c1": [
0.0,
0.0
],
"phase_samples": {
"phase_turn_in": 154,
"phase_held": 0,
"phase_release": 183,
"phase_reversal": 21
}
},
{
"name": "clean_release",
"role": "release",
"range_s": [
2453.714158177,
2456.964158177
],
"samples": 323,
"substantial_demand_required": false,
"recorded_can_ratio_02s_median": null,
"published_median_abs_c0_c1": [
0.0,
0.0
],
"send_clamped_median_abs_c0_c1": [
0.0,
0.0
],
"phase_samples": {
"phase_turn_in": 4,
"phase_held": 0,
"phase_release": 305,
"phase_reversal": 17
}
},
{
"name": "successful_smaller_positive",
"role": "sign_coverage_only",
"range_s": [
2590.722658577,
2600.918740146
],
"samples": 175,
"substantial_demand_required": true,
"recorded_can_ratio_02s_median": 1.0960646334373787,
"published_median_abs_c0_c1": [
0.42173025012016296,
0.1222948431968689
],
"send_clamped_median_abs_c0_c1": [
0.42173025012016296,
0.1222948431968689
],
"phase_samples": {
"phase_turn_in": 170,
"phase_held": 61,
"phase_release": 0,
"phase_reversal": 0
}
},
{
"name": "large_under_response",
"role": "under_response_challenge",
"range_s": [
2604.2254721,
2611.364366768
],
"samples": 128,
"substantial_demand_required": true,
"recorded_can_ratio_02s_median": 0.7322859508492778,
"published_median_abs_c0_c1": [
2.4204851388931274,
0.42145511507987976
],
"send_clamped_median_abs_c0_c1": [
2.4204851388931274,
0.42145511507987976
],
"phase_samples": {
"phase_turn_in": 68,
"phase_held": 96,
"phase_release": 56,
"phase_reversal": 0
}
},
{
"name": "successful_large_181deg",
"role": "authority_target",
"range_s": [
2744.478264791,
2750.573209708
],
"samples": 207,
"substantial_demand_required": true,
"recorded_can_ratio_02s_median": 1.0087938914780248,
"published_median_abs_c0_c1": [
2.1044259071350098,
0.3815947473049164
],
"send_clamped_median_abs_c0_c1": [
2.1044259071350098,
0.3815947473049164
],
"phase_samples": {
"phase_turn_in": 137,
"phase_held": 94,
"phase_release": 64,
"phase_reversal": 0
}
},
{
"name": "large_over_response_290deg",
"role": "over_response_challenge_not_target",
"range_s": [
2760.493612962,
2764.574374172
],
"samples": 181,
"substantial_demand_required": true,
"recorded_can_ratio_02s_median": 1.2515789463064766,
"published_median_abs_c0_c1": [
4.737145900726318,
0.5235000252723694
],
"send_clamped_median_abs_c0_c1": [
4.737145900726318,
0.5
],
"phase_samples": {
"phase_turn_in": 139,
"phase_held": 90,
"phase_release": 41,
"phase_reversal": 0
}
}
],
"selection": "Authority targets require automatic turn windows with >=1 second strict torque eligibility, eligible |wheel|>=150 degrees, and whole-window CAN response ratio median 0.90..1.10 at fixed 0.2 s. No positive-request large turn qualifies.",
"non_targets": "Positive smaller turn supplies sign coverage only. Under/over response and release/reversal windows are regression challenges, not authority targets.",
"context": "At least 10 s pre-roll or available route start, extended to include the preceding feedback reset/sign reversal. Overlapping intervals are merged. First episode begins at the partial route boundary with unobserved earlier history.",
"phase_policy": "Held means request curvature range over +/-0.25 s times speed squared <0.15 m/s2 at demand>=0.5. Turn-in/release compare current absolute curvature with the historical held request at measurement_time-delay, scaled by max(7,speed), using +/-0.0005 rad. These masks can overlap held; reversal means opposing delayed/current signs.",
"wire_policy": "Published coefficients preserve Float32 values. Send-clamped copy caps C0 to +/-5.11 and C1 to +/-0.5 before packing. Actual decoded wire is normalized to controller sign, nearest within 15 ms; wire_time/fresh/mode expose timing approximation.",
"model_schema": "models[model_index] contains position.x, position.y, orientation.z; Float32 conversion preserves the original model payload precision.",
"v5_reference": "Frozen full sequential replay from command_replay.npz, whose source hash and limitations are recorded in command_replay.json.",
"frozen_v5_revision": "09acf8ec2f327769f00ee53563ad2dd9225e37a7",
"preroll_validation": "Compact reset replay exactly matches full sequential frozen-v5 C0/C1, gates and bias on all 2233 evidence samples."
}
@@ -1,156 +0,0 @@
{
"description": "Anonymous recorded-input turn-exit regression fixture; command construction only, not simulated vehicle response.",
"baseline_revision": "dfcfddb91ce2409511f5b2dbce25d06d5056b3d6",
"baseline_hypothesis": "model-pose-c0-c1-feedback-v7",
"baseline_source_hashes": {
"controller_sha256": "4951a6352d89fcd66277bbfe682bd22e935a31b5a4db33e617ad21189b6705fd",
"allocator_sha256": "383538fc7cdae3bc28dffb71fe12ac5f3f9866ffbe6adfb7457f3593e9fc903a"
},
"fixture_sha256": "87a030c309061b7dc218715d05440c2077e465a8138079b46e8e8cee94201e54",
"source_fixture_sha256": "d476110b83dc628ffbd094220e464d6d3114b709bda2977813c3217964d41086",
"response_delay": 0.20000000298023224,
"publication_latency_estimate_s": 0.0015483515003040793,
"samples": 15273,
"model_count": 3078,
"evidence_samples": 4879,
"context_policy": "At least twenty seconds prior context, extended before the last observed reset. Overlapping intervals are merged.",
"provenance": "Selected from a recorded drive running the pinned baseline; request, model, driver and PSCM observations stay fixed during replay.",
"baseline_policy": "Stored commands, validity and bias exactly match the complete baseline replay on evidence samples. Context outside evidence initializes state and is not an exact-output target.",
"compact_full_baseline_evidence_parity": {
"commands": {
"exact": true,
"max_difference": 0.0
},
"valid": {
"exact": true,
"max_difference": 0.0
},
"heading_bias": {
"exact": true,
"max_difference": 0.0
}
},
"measurement_policy": "Controller computation time is estimated from publication time using the recorded median latency; exact vehicle motion under changed commands is unknown.",
"clean_policy": "Every sample from request time minus 0.5 s through plus 0.65 s is active, valid, fresh, unpressed and within 1 Nm raw driver torque. Demand is absolute desired curvature times current speed squared; substantial means at least 0.5 m/s2.",
"driver_policy": "All replay inputs retain driver interference; only comparison metrics use the clean mask. History-reset failures intentionally retain nearby driver context.",
"coordinates": "Elapsed seconds shifted to the first fixture control cycle; model x/y/heading are vehicle-relative, not global position.",
"retained_fields": [
"t",
"episode",
"model_index",
"models",
"desired_curvature",
"yaw_rate",
"speed",
"measurement_time",
"model_time",
"reference_time",
"active",
"valid",
"pressed",
"steering_torque",
"pscm_timestamp",
"pscm_valid",
"pscm_lateral_state",
"pscm_limit",
"pscm_capability",
"pscm_denied",
"clean_rawtorque",
"demand",
"window_masks",
"evidence",
"baseline_commands",
"baseline_valid",
"baseline_heading_base",
"baseline_heading_target",
"baseline_heading_bias",
"baseline_feedback_yaw_error",
"baseline_feedback_reference_curvature",
"baseline_status",
"baseline_offset_target"
],
"omitted_data": "No route/device identifiers, VIN, GPS, private paths, raw wheel angle, wheel rate, EPS torque, or absolute clock origins.",
"baseline_status_meaning": "feedback_status from the pinned baseline",
"windows": [
{
"name": "good_curve_a",
"role": "comparison",
"range_s": [
20.0002130975003,
25.0002130975003
],
"samples": 496,
"clean_substantial_samples": 259
},
{
"name": "first_reversal",
"role": "reversal",
"range_s": [
83.0002130975003,
92.7002130975003
],
"samples": 964,
"clean_substantial_samples": 167
},
{
"name": "good_curve_b",
"role": "comparison",
"range_s": [
121.0002130975003,
128.0002130975003
],
"samples": 695,
"clean_substantial_samples": 308
},
{
"name": "second_reversal",
"role": "reversal",
"range_s": [
133.5002130975003,
138.9002130975003
],
"samples": 537,
"clean_substantial_samples": 191
},
{
"name": "large_turn_driver_context_a",
"role": "driver_context",
"range_s": [
150.0002130975003,
157.0002130975003
],
"samples": 695,
"clean_substantial_samples": 0
},
{
"name": "over_growth",
"role": "over_response",
"range_s": [
182.0002130975003,
191.0002130975003
],
"samples": 897,
"clean_substantial_samples": 66
},
{
"name": "large_turn_driver_context_b",
"role": "driver_context",
"range_s": [
199.0002130975003,
205.0002130975003
],
"samples": 595,
"clean_substantial_samples": 281
},
{
"name": "zero_bias_release",
"role": "under_response",
"range_s": [
202.0002130975003,
205.0002130975003
],
"samples": 297,
"clean_substantial_samples": 279
}
]
}
@@ -1,59 +0,0 @@
import ast
import io
import json
import logging
from pathlib import Path
from types import SimpleNamespace
import unittest
from openpilot.common.logging_extra import SwagFormatter, SwagLogger
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController
from openpilot.selfdrive.controls.lib.ford_path import FordPathController, FordPscmObserverPathController
from openpilot.selfdrive.controls.tests.test_ford_model_action import circle
class TestFordControlsLogging(unittest.TestCase):
def emit_controls_event(self, event, controls):
# Execute the actual controlsd call with the real logger and formatter,
# without launching hardware-dependent Controls or opening logging IPC.
source_path = Path(__file__).resolve().parents[1] / 'controlsd.py'
source = ast.parse(source_path.read_text())
calls = [node for node in ast.walk(source) if isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name)
and node.func.value.id == 'cloudlog' and node.args
and isinstance(node.args[0], ast.Constant) and node.args[0].value == event]
self.assertEqual(len(calls), 1)
logger = SwagLogger()
logger.setLevel(logging.INFO) # disabled INFO logging would hide this crash
stream = io.StringIO()
handler = logging.StreamHandler(stream)
handler.setFormatter(SwagFormatter(logger))
logger.addHandler(handler)
try:
expression = ast.Expression(body=calls[0])
eval(compile(expression, str(source_path), 'eval'), {'cloudlog': logger, 'self': controls, 'reference_service': 'modelV2'})
record = json.loads(stream.getvalue())
finally:
handler.close()
self.assertEqual(record['level'], 'INFO')
self.assertEqual(record['msg']['event'], event)
return record['msg']
def test_startup_logs_selected_controller_without_crashing(self):
for controller in (FordPathController(), FordPscmObserverPathController(), FordModelActionController()):
with self.subTest(controller=type(controller).__name__):
record = self.emit_controls_event('Ford path controller selected', SimpleNamespace(ford_path_controller=controller))
self.assertEqual(record['controller'], type(controller).__name__)
def test_candidate_diagnostics_identify_the_experiment_and_do_not_claim_calibration(self):
controller = FordModelActionController()
for active, valid in ((False, True), (True, True), (True, False)):
controller.update(circle(.01), .005, current_curvature=.0025, yaw_rate=.05, speed=20., now=1.,
measurement_time=1., model_time=1., reference_time=1., active=active, valid=valid)
controls = SimpleNamespace(ford_path_controller=controller, desired_curvature=.005, curvature=.0025,
sm=SimpleNamespace(logMonoTime={'modelV2': 123456789, 'carState': 123450000}))
record = self.emit_controls_event('Ford C2-free path tracking', controls)
self.assertEqual(record['hypothesis'], 'model-action-c1-feedback-v1')
self.assertIs(record['calibration_approved'], False)
self.assertEqual(record['command'][2:], [0., 0.])
self.assertEqual(record['status'], controller.diagnostics['status'])
@@ -1,193 +0,0 @@
import math
from types import SimpleNamespace
import numpy as np
import pytest
from opendbc.can import CANPacker, CANParser
from opendbc.car.ford.fordcan import CanBus, create_lat_ctl2_msg
from openpilot.cereal import custom
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.lib.ford_model_action import ModelActionController, encode_model_action
def make_model(x, y, heading):
return SimpleNamespace(position=SimpleNamespace(x=x, y=y), orientation=SimpleNamespace(z=heading))
def circle(curvature):
s = np.linspace(0., 60., 601)
return make_model(np.sin(curvature*s)/curvature, (1-np.cos(curvature*s))/curvature, curvature*s)
def straight(offset=0.):
x = np.linspace(0., 60., 121)
return make_model(x, np.full_like(x, offset), np.zeros_like(x))
def test_selected_action_controls_heading_even_when_model_previews_another_turn():
model = circle(.02)
assert encode_model_action(model, 0., 20.).path_angle == 0.
assert encode_model_action(model, -.004, 20.).path_angle == pytest.approx(-.08)
assert encode_model_action(model, 0., 20.).path_offset > 0.
def test_centering_information_is_independent_of_action_and_not_scaled_with_speed():
for speed in (2., 7., 20., 35.):
target = encode_model_action(straight(.4), 0., speed)
assert target == FordPath(True, .4, 0., 0., 0.)
for sign in (-1, 1):
target = encode_model_action(circle(sign*.01), sign*.01, 20.)
assert target.path_offset == pytest.approx(sign*(1-math.cos(.07))/.01, abs=1e-6)
assert target.path_angle == pytest.approx(sign*.2) # No 10 m cap at highway speed.
def test_three_control_states_are_sufficient_for_every_next_output():
controller = ModelActionController()
assert not hasattr(controller, '__dict__')
for i in range(300):
copied = ModelActionController()
copied.c0, copied.c1, copied.correction = controller.c0, controller.c1, controller.correction
model = straight(.2*math.sin(i*.1))
kwargs = {'speed': 20., 'dt': .01}
desired = .005*math.cos(i*.03)
assert controller.update(model, desired, current_curvature=0., **kwargs) == copied.update(model, desired, current_curvature=0., **kwargs)
def test_held_turn_releases_without_a_bias_tail_or_sign_reversal():
for sign in (-1., 1.):
controller = ModelActionController()
for _ in range(400):
out = controller.update(circle(sign*.01), sign*.01, current_curvature=sign*.01, speed=20., dt=.01)
assert out.path_angle == pytest.approx(sign*.2)
previous = np.array([out.path_offset, out.path_angle])
for desired in sign*np.linspace(.01, 0., 101):
out = controller.update(straight(), desired, current_curvature=desired, speed=20., dt=.01)
values = np.array([out.path_offset, out.path_angle])
assert (abs(values) <= abs(previous)+1e-8).all()
assert (sign*values >= -1e-8).all()
previous = values
assert out == FordPath(True, 0., 0., 0., 0.)
def test_current_model_replacement_leaves_only_independent_actuator_slew():
controller = ModelActionController()
for _ in range(150):
controller.update(straight(1.), .04, current_curvature=.04, speed=20., dt=.01)
for _ in range(25):
out = controller.update(straight(), 0., current_curvature=0., speed=20., dt=.01)
assert out.path_offset == pytest.approx(0.)
assert out.path_angle > 0. # C1 cannot hold C0 during its longer release.
for _ in range(75):
out = controller.update(straight(), 0., current_curvature=0., speed=20., dt=.01)
assert out == FordPath(True, 0., 0., 0., 0.)
@pytest.mark.parametrize('overrides', [{'active': False}, {'valid': False}, {'dt': .2}, {'speed': math.nan}])
def test_invalid_or_inactive_input_clears_state_before_reengagement(overrides):
controller = ModelActionController()
for _ in range(100):
controller.update(straight(.5), .01, current_curvature=.01, speed=20., dt=.01)
kwargs = {'speed': 20., 'dt': .01, 'active': True, 'valid': True}
kwargs.update(overrides)
assert controller.update(straight(), 0., current_curvature=0., **kwargs) == FordPath()
assert (controller.c0, controller.c1) == (0., 0.)
assert controller.update(straight(), 0., current_curvature=0., speed=20., dt=.01) == FordPath(True, 0., 0., 0., 0.)
def test_malformed_geometry_and_nonfinite_action_never_create_an_active_command():
for model, desired in ((None, 0.), (straight(), math.nan), (straight(), math.inf)):
assert not encode_model_action(model, desired, 20.).valid
def test_selected_core_reversal_through_float32_and_wire_keeps_sign_and_zero_c2():
controller = ModelActionController()
packer = CANPacker('ford_lincoln_base_pt')
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], 0)
bus = CanBus(fingerprint={0: {}})
previous = np.zeros(2)
for i in range(600):
sign = 1. if i < 300 else -1.
out = controller.update(straight(sign*8.), sign*.1, current_curvature=sign*.1, speed=30., dt=.01)
fields = np.array([out.path_offset, out.path_angle])
assert (abs(fields) <= [5.1100001, .5000001]).all()
assert (abs(fields-previous) <= [.0500001, .0055001]).all()
previous = fields
message = custom.CarControlSP.new_message()
message.fordLateralPath.pathOffset = out.path_offset
message.fordLateralPath.pathAngle = out.path_angle
packet = create_lat_ctl2_msg(packer, bus, 2, -message.fordLateralPath.pathOffset,
-message.fordLateralPath.pathAngle, out.curvature, out.curvature_rate, i % 16)
parser.update([i*10_000_000, [packet]])
decoded = parser.vl['LateralMotionControl2']
assert decoded['LatCtlPathOffst_L_Actl'] == pytest.approx(-out.path_offset)
assert decoded['LatCtlPath_An_Actl'] == pytest.approx(-out.path_angle)
assert decoded['LatCtlCurv_No_Actl'] == decoded['LatCtlCrv_NoRate2_Actl'] == 0.
def test_short_path_holds_available_endpoint_without_extrapolation():
model = make_model([0., 1.], [0., .1], [0., 0.])
assert encode_model_action(model, .01, 20.) == FordPath(True, .1, .2, 0., 0.)
def test_overflowing_arc_resets_instead_of_publishing_invalid_geometry():
model = make_model([0., 1e308, -1e308], [0., 0., 0.], [0., 0., 0.])
controller = ModelActionController()
controller.update(straight(.4), .01, current_curvature=.01, speed=20., dt=.01)
assert controller.update(model, .01, current_curvature=.01, speed=20., dt=.01) == FordPath()
assert (controller.c0, controller.c1) == (0., 0.)
@pytest.mark.parametrize('value', [None, 'bad', 10**400])
@pytest.mark.parametrize('field', ['dt', 'speed', 'desired_curvature'])
def test_malformed_numeric_input_resets_without_throwing(field, value):
controller = ModelActionController()
kwargs = {'speed': 20., 'dt': .01, 'desired_curvature': .01}
controller.update(straight(.4), current_curvature=kwargs['desired_curvature'], **kwargs)
kwargs[field] = value
assert controller.update(straight(.4), current_curvature=kwargs['desired_curvature'], **kwargs) == FordPath()
assert (controller.c0, controller.c1) == (0., 0.)
@pytest.mark.parametrize('model', [
make_model([], [], []), make_model([0.], [0.], [0.]),
make_model([0., 10.], [0.], [0., 0.]), make_model([0., 10.], [0., 0.], [0.]),
make_model([0., 0.], [0., 0.], [0., 0.]),
make_model([0., 10.], [0., math.nan], [0., 0.]), make_model([0., math.inf], [0., 0.], [0., 0.]),
make_model([0., 10.], [0., 0.], [0., math.inf]),
make_model([0., 10**400], [0., 0.], [0., 0.]),
make_model([0., 10.], [0., 0.], [1e308, -1e308]),
])
def test_malformed_model_arrays_cannot_reuse_a_previous_valid_command(model):
controller = ModelActionController()
controller.update(straight(.4), .01, current_curvature=.01, speed=20., dt=.01)
assert controller.update(model, .01, current_curvature=.01, speed=20., dt=.01) == FordPath()
assert (controller.c0, controller.c1) == (0., 0.)
@pytest.mark.parametrize('field,value,valid', [
('speed', .2999, False), ('speed', .3, True), ('speed', 55., True), ('speed', 55.0001, False),
('desired_curvature', -1., True), ('desired_curvature', 1., True), ('desired_curvature', -1.0001, False),
('dt', .001999, False), ('dt', .002, True), ('dt', .1, True), ('dt', .100001, False), ('dt', 0., False),
])
def test_domain_and_elapsed_time_boundaries(field, value, valid):
kwargs = {'speed': 20., 'desired_curvature': .01, 'dt': .01}
kwargs[field] = value
assert ModelActionController().update(straight(.4), current_curvature=kwargs['desired_curvature'], **kwargs).valid == valid
def test_arc_station_not_forward_x_or_model_heading_determines_offset():
x = np.array([0., 6., 12.])
y = .4+x*.75
target = encode_model_action(make_model(x, y, [2., -2., 1.]), -.01, 20.)
# Arc length is 1.25*x on this line, so y(arc=7)=.4+.75*(7/1.25).
assert target.path_offset == pytest.approx(4.6)
assert target.path_angle == pytest.approx(-.2)
def test_duplicate_stations_keep_valid_geometry_and_first_cycle_slew():
model = make_model([0., 0., 10.], [.4, .4, .4], [0., 0., 0.])
assert encode_model_action(model, .01, 20.) == FordPath(True, .4, .2, 0., 0.)
out = ModelActionController().update(model, .01, current_curvature=.01, speed=20., dt=.002)
assert out.path_offset == pytest.approx(.01)
assert out.path_angle == pytest.approx(.001)
@@ -1,291 +0,0 @@
"""Exercise the candidate through existing selection, publication and CAN code.
Tests enable the candidate through controlsd's real startup selection.
No hardware, IPC or CAN transmission is involved.
"""
import ast
from collections import defaultdict
import json
import math
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.can import CANParser
from opendbc.car import Bus, structs
from opendbc.car.ford.carcontroller import CarController
from opendbc.car.ford.fordcan import calculate_lat_ctl2_checksum
from opendbc.car.ford.values import FordFlags
from openpilot.cereal import custom
from openpilot.selfdrive.car.helpers import convert_carControlSP
from openpilot.selfdrive.controls.lib.drive_helpers import clip_curvature
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.tests.test_ford_model_action import circle, straight
from openpilot.selfdrive.controls.tests.test_ford_model_action_selection import startup
def update(controller, now=1., **overrides):
kwargs = {'model': straight(.4), 'desired_curvature': .01, 'speed': 20., 'yaw_rate': 0., 'now': now,
'model_time': now, 'measurement_time': now, 'reference_time': now, 'active': True}
kwargs.update(overrides)
kwargs.setdefault('current_curvature', kwargs['desired_curvature']) # Preserve feedforward-only compatibility probes.
return controller.update(**kwargs)
@pytest.mark.parametrize('field', ['model_time', 'measurement_time', 'reference_time'])
@pytest.mark.parametrize('age', [.151, -.006])
def test_stale_or_future_service_clears_commands_and_reengages_from_zero(field, age):
controller = FordModelActionController()
update(controller)
assert update(controller, 1.01, **{field: 1.01-age}) == FordPath()
assert controller.diagnostics['status'] == 'stale_input'
assert update(controller, 1.02).path_offset == pytest.approx(.04)
@pytest.mark.parametrize('change,reason', [
({'now': 1.}, 'timing_reset'),
({'now': .99}, 'timing_reset'),
({'now': 1.001}, 'timing_reset'),
({'now': 1.101}, 'timing_reset'),
({'model_time': .999}, 'timing_reset'),
({'measurement_time': .999}, 'timing_reset'),
({'active': False}, 'inactive'),
({'valid': False}, 'invalid_service'),
({'model': None}, 'invalid_path'),
({'yaw_rate': math.nan}, 'nonfinite'),
({'yaw_rate': 3.01}, 'input_range'),
({'speed': 55.01}, 'input_range'),
({'desired_curvature': 1.01}, 'input_range'),
])
def test_invalid_cycle_never_keeps_a_previous_active_request(change, reason):
controller = FordModelActionController()
update(controller)
now = change.get('now', 1.01)
assert update(controller, **dict(change, now=now)) == FordPath()
assert controller.diagnostics['status'] == reason
assert (controller.core.c0, controller.core.c1) == (0., 0.)
assert update(controller, now+1.).path_angle == pytest.approx(.005)
@pytest.mark.parametrize('field', ['now', 'measurement_time', 'model_time', 'reference_time', 'speed', 'yaw_rate', 'desired_curvature'])
@pytest.mark.parametrize('value', [math.nan, math.inf, -math.inf, None])
def test_nonfinite_input_never_raises_or_leaks_into_diagnostics(field, value):
controller = FordModelActionController()
update(controller)
assert update(controller, **{field: value}) == FordPath()
assert controller.diagnostics['status'] == 'nonfinite'
json.dumps(controller.diagnostics, allow_nan=False)
def test_repeated_measurements_do_not_freeze_slew_or_cache_invalid_model_geometry():
controller = FordModelActionController()
for i in range(10):
result = update(controller, 1.+i*.01, measurement_time=1., model_time=1., reference_time=1.)
assert result.path_offset == pytest.approx(.4)
assert result.path_angle == pytest.approx(.05)
broken = straight(.4)
broken.position.y[5] = math.nan
assert update(controller, 1.1, model=broken, model_time=1., measurement_time=1.) == FordPath()
assert controller.diagnostics['status'] == 'invalid_path'
def test_yaw_offset_does_not_change_the_base():
controllers = [FordModelActionController() for _ in range(3)]
variants = [{}, {'yaw_rate': .0072}, {'yaw_rate': -.0072}]
for i in range(100):
outputs = [update(c, 1.+i*.01, **kwargs) for c, kwargs in zip(controllers, variants, strict=True)]
assert all(out == outputs[0] for out in outputs)
assert outputs[0].path_angle == pytest.approx(.2)
def test_reference_source_can_change_to_an_older_but_fresh_publication():
controller = FordModelActionController()
update(controller, reference_time=.99)
assert update(controller, 1.01, reference_time=.98).valid
def test_release_keeps_current_geometry_and_may_grow_c0_while_c1_decreases():
for sign in (-1., 1.):
controller = FordModelActionController()
for i in range(100):
before = update(controller, 1.+i*.01, model=circle(sign*.01), desired_curvature=sign*.005)
for i in range(100):
after = update(controller, 2.+i*.01, model=circle(sign*.02), desired_curvature=sign*.004)
assert abs(after.path_offset) > abs(before.path_offset)
assert abs(after.path_angle) < abs(before.path_angle)
for i in range(100):
released = update(controller, 3.+i*.01, model=circle(sign*.02), desired_curvature=0.)
assert released.path_offset == after.path_offset
assert released.path_angle == pytest.approx(0.)
def _method(filename, class_name, method):
tree = ast.parse(filename.read_text())
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == class_name)
return next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == method)
@pytest.fixture
def pipeline():
root = Path(__file__).resolve().parents[3]
controls_file = root/'selfdrive/controls/controlsd.py'
body = _method(controls_file, 'Controls', 'state_control').body
# Execute the actual source choice, upstream limiter and Ford integration.
selection = next(n for n in body if isinstance(n, ast.If) and ast.unparse(n.test) == "self.sm.valid['lateralManeuverPlan']")
limiter = next(n for n in body if isinstance(n, ast.Assign) and isinstance(n.value, ast.Call) and
isinstance(n.value.func, ast.Name) and n.value.func.id == 'clip_curvature')
branch = next(n for n in body if isinstance(n, ast.If) and ast.unparse(n.test) == "self.CP.brand == 'ford'")
call = compile(ast.Module(body=[selection, limiter, branch], type_ignores=[]), str(controls_file), 'exec')
publication_file = root/'sunnypilot/selfdrive/controls/controlsd_ext.py'
body = _method(publication_file, 'ControlsExt', 'state_control_ext').body
publish = [n for n in body if (isinstance(n, ast.Assign) and ast.unparse(n.targets[0]) == 'ford_path') or
(isinstance(n, ast.If) and ast.unparse(n.test) == 'ford_path is not None')]
assert len(publish) == 2
publication = compile(ast.Module(body=publish, type_ignores=[]), str(publication_file), 'exec')
return call, publication
class Subscriptions:
frame = 1
def __init__(self, maneuver):
self.valid = {'lateralManeuverPlan': maneuver, 'modelV2': True, 'carStateSP': True}
self.logMonoTime = {'carState': 995_000_000, 'modelV2': 980_000_000, 'lateralManeuverPlan': 990_000_000}
self.failed = set()
self.messages = {'carStateSP': custom.CarStateSP.new_message(), 'lateralManeuverPlan': SimpleNamespace(desiredCurvature=-.1)}
def __getitem__(self, service):
return self.messages[service]
def all_checks(self, services):
return not self.failed.intersection(services) and all(self.valid.get(s, True) for s in services)
@pytest.mark.parametrize('maneuver', [False, True])
def test_actual_controlsd_selection_limiting_publication_and_downstream_can(pipeline, maneuver):
call, publication = pipeline
sm = Subscriptions(maneuver)
controls = startup()
controller = controls.ford_path_controller
controls.sm, controls.desired_curvature, controls.curvature = sm, 0., 0.
model = straight(.4)
model.action = SimpleNamespace(desiredCurvature=.1)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=-.0072, canValid=True, steeringPressed=False, steeringTorque=0.)
environment = {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)}
exec(call, environment)
expected_curvature = (-1 if maneuver else 1)*.000125
assert controls.desired_curvature == pytest.approx(expected_curvature)
assert controls.ford_path.path_angle == pytest.approx(20.*expected_curvature)
assert controls.ford_path.path_offset == pytest.approx(.04)
assert cc.latActive and cc.actuators.curvature == 0.
assert controller.diagnostics['reference_age'] == pytest.approx(.01 if maneuver else .02)
cp = structs.CarParams(flags=int(FordFlags.CANFD), carFingerprint='FORD_F_150_LIGHTNING_MK1')
downstream = CarController({Bus.pt: 'ford_lincoln_base_pt'}, cp, structs.CarParamsSP())
vehicle = SimpleNamespace(out=structs.CarState(vEgo=20., vEgoRaw=20.), acc_tja_status_stock_values=defaultdict(int),
lkas_status_stock_values=defaultdict(int), buttons_stock_values=defaultdict(int))
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], downstream.CAN.main)
for i, fail in enumerate((False, True)):
if fail:
sm.failed.add('modelV2')
exec(call, environment)
assert not cc.latActive and controls.ford_path == FordPath()
msg = custom.CarControlSP.new_message()
exec(publication, {'self': controls, 'CC_SP': msg})
_, packets = downstream.update(cc.as_reader(), convert_carControlSP(msg.as_reader()), vehicle, (i+1)*10_000_000)
parser.update([(i+1)*10_000_000, packets])
wire = parser.vl['LateralMotionControl2']
assert wire['LatCtlPathOffst_L_Actl'] == pytest.approx(-controls.ford_path.path_offset)
assert wire['LatCtlPath_An_Actl'] == pytest.approx(-controls.ford_path.path_angle)
assert wire['LatCtlCurv_No_Actl'] == wire['LatCtlCrv_NoRate2_Actl'] == 0.
assert wire['LatCtl_D2_Rq'] == (0 if fail else 2)
@pytest.mark.parametrize('maneuver', [False, True])
@pytest.mark.parametrize('failed', ['carState', 'modelV2', 'vehicleParameters', 'lateralManeuverPlan'])
def test_actual_controlsd_service_gates(pipeline, maneuver, failed):
sm = Subscriptions(maneuver)
sm.failed.add(failed)
controls = startup()
controls.sm, controls.desired_curvature, controls.curvature = sm, 0., 0.
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
model = straight()
model.action = SimpleNamespace(desiredCurvature=.1)
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model, 'lp': SimpleNamespace(roll=0.),
'clip_curvature': clip_curvature, 'time': SimpleNamespace(monotonic=lambda: 1.)})
assert controls.ford_path.valid == cc.latActive == (failed == 'lateralManeuverPlan' and not maneuver)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_feedback_through_actual_controlsd_publication_and_100hz_sender(pipeline, sign):
call, publication = pipeline
controls, sm = startup(), Subscriptions(False)
controls.sm, controls.desired_curvature = sm, sign*.004
model = straight(.4)
model.action = SimpleNamespace(desiredCurvature=sign*.004)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=.2, canValid=True, steeringPressed=False, steeringTorque=0.)
cp = structs.CarParams(flags=int(FordFlags.CANFD), carFingerprint='FORD_F_150_LIGHTNING_MK1')
downstream = CarController({Bus.pt: 'ford_lincoln_base_pt'}, cp, structs.CarParamsSP())
vehicle = SimpleNamespace(out=structs.CarState(vEgo=20., vEgoRaw=20.), acc_tja_status_stock_values=defaultdict(int),
lkas_status_stock_values=defaultdict(int), buttons_stock_values=defaultdict(int))
parser = CANParser('ford_lincoln_base_pt', [('LateralMotionControl2', 100)], downstream.CAN.main)
frame = 0
for measured, torque, count, expected in [(sign*.004, 0., 100, 0.), (sign*.003, 0., 100, sign*.02),
(sign*.004, 0., 100, sign*.02), (sign*.005, 0., 100, 0.),
(sign*.003, 0., 100, sign*.02), (0., 1.0625, 5, 0.)]:
for _ in range(count):
now = 1.+frame*.01
controls.curvature, cs.steeringTorque = measured, torque
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
environment = {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)}
exec(call, environment)
msg = custom.CarControlSP.new_message()
exec(publication, {'self': controls, 'CC_SP': msg})
_, packets = downstream.update(cc.as_reader(), convert_carControlSP(msg.as_reader()), vehicle, round(now*1e9))
received = parser.update([round(now*1e9), packets])
assert parser.dbc.name_to_msg['LateralMotionControl2'].address in received
wire = parser.vl['LateralMotionControl2']
assert wire['LatCtlPath_An_Actl'] == pytest.approx(-controls.ford_path.path_angle)
assert wire['LatCtlPathOffst_L_Actl'] == pytest.approx(-controls.ford_path.path_offset)
assert wire['LatCtlCurv_No_Actl'] == wire['LatCtlCrv_NoRate2_Actl'] == 0.
assert wire['LatCtl_D2_Rq'] == 2
assert wire['LatCtlPath_No_Cnt'] == frame % 16
address = parser.dbc.name_to_msg['LateralMotionControl2'].address
packet = next(packet for packet in packets if packet[0] == address)
assert wire['LatCtlPath_No_Cs'] == calculate_lat_ctl2_checksum(2, frame % 16, packet[1])
frame += 1
assert controls.ford_path_controller.core.correction == pytest.approx(expected)
assert controls.ford_path.path_angle == pytest.approx(sign*.08+expected)
assert controls.ford_path.path_offset == pytest.approx(.4)
@pytest.mark.parametrize('service_valid', [False, True])
def test_actual_controlsd_passes_only_valid_pscm_service_to_feedback(pipeline, service_valid):
controls, sm = startup(), Subscriptions(False)
controls.sm, controls.desired_curvature = sm, .004
model = straight(.4)
model.action = SimpleNamespace(desiredCurvature=.004)
cc = structs.CarControl(latActive=True)
cs = SimpleNamespace(vEgo=20., yawRate=0., canValid=True, steeringPressed=False, steeringTorque=0.)
for frame in range(101):
now = 1.+frame*.01
controls.curvature = .004 if frame < 100 else .003
sm.logMonoTime.update(carState=round(now*1e9), modelV2=round(now*1e9))
sm.valid['carStateSP'] = service_valid
status = sm['carStateSP'].fordPscmStatus
status.valid, status.canMonoTime, status.limit, status.lateralState = True, round(now*1e9), 2, 2
exec(pipeline[0], {'self': controls, 'CS': cs, 'CC': cc, 'actuators': cc.actuators, 'model_v2': model,
'lp': SimpleNamespace(roll=0.), 'clip_curvature': clip_curvature,
'time': SimpleNamespace(monotonic=lambda now=now: now)})
controller = controls.ford_path_controller
assert controller.diagnostics['pscm_limited'] is service_valid
assert controller.core.correction == pytest.approx(0. if service_valid else .0002)
assert cc.latActive and controls.ford_path.valid
@@ -1,182 +0,0 @@
"""C1 feedback behavior; these tests do not simulate a Ford steering plant."""
import math
from types import SimpleNamespace
import pytest
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, ModelActionController
from openpilot.selfdrive.controls.lib.ford_path import FordPath
from openpilot.selfdrive.controls.tests.test_ford_model_action import straight
def tick(controller, desired, measured, **overrides):
kwargs = {'current_curvature': measured, 'speed': 20., 'dt': .01}
kwargs.update(overrides)
return controller.update(straight(.4), desired, **kwargs)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_feedback_builds_holds_and_unwinds_without_changing_c0(sign):
controller, matched = ModelActionController(), ModelActionController()
for _ in range(100):
tick(controller, sign*.004, sign*.004)
for _ in range(100):
out = tick(controller, sign*.004, sign*.003)
baseline = tick(matched, sign*.004, sign*.004)
assert controller.correction == pytest.approx(sign*.02)
assert out.path_angle == pytest.approx(sign*.1)
assert out.path_offset == baseline.path_offset == pytest.approx(.4)
for _ in range(100):
out = tick(controller, sign*.004, sign*.004)
assert controller.correction == pytest.approx(sign*.02)
assert out.path_angle == pytest.approx(sign*.1)
for _ in range(200):
out = tick(controller, sign*.004, sign*.005)
assert controller.correction == pytest.approx(-sign*.02)
assert out.path_angle == pytest.approx(sign*.06)
assert out.curvature == out.curvature_rate == 0.
@pytest.mark.parametrize('sign', [-1., 1.])
def test_amplitude_and_slew_limits_do_not_store_unavailable_feedback(sign):
controller = ModelActionController()
# The unchanged model request is already ahead of the output slew.
for _ in range(10):
tick(controller, sign*.01, 0.)
assert controller.correction == 0.
for _ in range(1000):
before = controller.c1
tick(controller, sign*.01, -sign*.9)
assert abs(controller.c1-before) <= .0050000001
assert abs(controller.correction) <= .3000000001
assert controller.c1 == pytest.approx(sign*.5)
assert controller.correction == pytest.approx(sign*.3)
for _ in range(200):
tick(controller, sign*.01, 0.)
assert controller.correction == pytest.approx(sign*.3)
tick(controller, sign*.01, sign*.02)
assert sign*controller.correction < .3 # Unwind is allowed at the cap.
assert sign*controller.c1 < .5
@pytest.mark.parametrize('sign', [-1., 1.])
def test_pscm_limit_only_blocks_feedback_further_into_measured_turn(sign):
controller = ModelActionController()
for _ in range(100):
tick(controller, sign*.004, sign*.004)
for _ in range(100):
tick(controller, sign*.004, sign*.003, pscm_limited=True)
assert controller.correction == 0.
out = tick(controller, sign*.004, sign*.005, pscm_limited=True)
assert sign*controller.correction < 0.
# A limit cannot stall the new model request itself or its unwind slew.
for _ in range(100):
out = tick(controller, 0., 0., pscm_limited=True)
assert abs(out.path_angle) < .001
assert out.path_offset == pytest.approx(.4)
@pytest.mark.parametrize('sign', [-1., 1.])
def test_pscm_limit_cannot_trap_old_correction_below_the_model_request(sign):
controller = ModelActionController()
controller.correction = -sign*.02
controller.c1 = sign*.06
for _ in range(200):
out = tick(controller, sign*.004, sign*.003, pscm_limited=True)
assert controller.correction == pytest.approx(0.)
assert out.path_angle == pytest.approx(sign*.08)
def test_driver_intervention_clears_feedback_through_existing_output_slew():
controller = ModelActionController()
for _ in range(100):
tick(controller, .004, .004)
for _ in range(100):
tick(controller, .004, .003)
assert controller.correction > 0.
previous = controller.c1
tick(controller, .004, -.01, feedback_enabled=False)
assert controller.correction == 0.
assert abs(controller.c1-previous) <= .0050000001
for _ in range(100):
out = tick(controller, .004, -.01, feedback_enabled=False)
assert controller.correction == 0.
assert out.path_angle == pytest.approx(.08)
@pytest.mark.parametrize('field,value', [('current_curvature', math.nan), ('current_curvature', None),
('current_curvature', 1.01), ('feedback_dt', math.nan),
('feedback_dt', -.001), ('feedback_dt', .151), ('active', False)])
def test_bad_feedback_inputs_and_disengagement_clear_every_control_state(field, value):
controller = ModelActionController()
controller.correction = .03
out = tick(controller, .004, .003, **{field: value})
assert out == FordPath()
assert (controller.c0, controller.c1, controller.correction) == (0., 0., 0.)
def adapter_tick(controller, now, **overrides):
kwargs = {'current_curvature': .003, 'speed': 20., 'yaw_rate': 0., 'now': now,
'measurement_time': now, 'model_time': now, 'reference_time': now, 'active': True}
kwargs.update(overrides)
return controller.update(straight(.4), .004, **kwargs)
def status(now, **overrides):
fields = {'valid': True, 'canMonoTime': round(now*1e9), 'limit': 0, 'lateralState': 2, 'denied': False}
fields.update(overrides)
return SimpleNamespace(**fields)
def test_repeated_steering_samples_only_advance_output_slew():
controller = FordModelActionController()
for i in range(100):
adapter_tick(controller, 1.+i*.01, current_curvature=.004)
before = controller.core.correction
for i in range(1, 6):
adapter_tick(controller, 1.99+i*.01, measurement_time=1.99)
assert controller.core.correction == before
adapter_tick(controller, 2.05)
assert controller.core.correction == pytest.approx(.02*.06)
assert controller.diagnostics['feedback_dt'] == pytest.approx(.06)
@pytest.mark.parametrize('overrides', [{'driver_pressed': True}, {'driver_torque': 1.01},
{'driver_torque': -1.01}, {'driver_torque': math.nan},
{'pscm_status': status(2.01, limit=3)},
{'pscm_status': status(2.01, denied=True)},
{'pscm_status': status(2.01, lateralState=1)}])
def test_adapter_clears_feedback_when_driver_or_pscm_overrides(overrides):
controller = FordModelActionController()
for i in range(101):
adapter_tick(controller, 1.+i*.01)
assert controller.core.correction > 0.
assert adapter_tick(controller, 2.01, **overrides).valid
assert controller.core.correction == 0.
assert not controller.diagnostics['feedback_enabled']
@pytest.mark.parametrize('overrides,limited', [({}, True), ({'valid': False}, False),
({'canMonoTime': 0}, False), ({'canMonoTime': 1_800_000_000}, False),
({'canMonoTime': 2_020_000_000}, False), ({'limit': 1}, False)])
def test_only_fresh_reached_pscm_limit_blocks_outward_integration(overrides, limited):
controller = FordModelActionController()
for i in range(100):
adapter_tick(controller, 1.+i*.01, current_curvature=.004)
adapter_tick(controller, 2., pscm_status=status(2., **{'limit': 2, **overrides}))
assert controller.diagnostics['pscm_limited'] is limited
assert (controller.core.correction == 0.) is limited
def test_measurement_cadence_preserves_elapsed_distance_integration():
results = []
for period in (1, 2, 5):
controller = FordModelActionController()
for i in range(101):
now = 1.+i*.01
adapter_tick(controller, now, current_curvature=.004)
for i in range(1, 101):
now = 2.+i*.01
adapter_tick(controller, now, measurement_time=2.+(i//period)*period*.01)
results.append(controller.core.correction)
assert results == pytest.approx([.02, .02, .02])
@@ -1,100 +0,0 @@
"""Exercise real startup selection and Sunnylink writes without starting hardware."""
import ast
import base64
import itertools
from pathlib import Path
from types import SimpleNamespace
import pytest
from opendbc.car.ford.values import FordFlags
from openpilot.common.params import Params, ParamKeyFlag, ParamKeyType
from openpilot.selfdrive.controls.lib.ford_model_action import FordModelActionController, select_model_action_controller
from openpilot.selfdrive.controls.lib.ford_path import FordPath, FordPathController, FordPscmObserverPathController
def car_params(**overrides):
return SimpleNamespace(**({'brand': 'ford', 'flags': FordFlags.CANFD, 'carFingerprint': 'FORD_F_150_LIGHTNING_MK1',
'carFw': []} | overrides))
def startup(cp=None, params=None):
filename = Path(__file__).resolve().parents[1]/'controlsd.py'
tree = ast.parse(filename.read_text())
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'Controls')
body = next(n for n in cls.body if isinstance(n, ast.FunctionDef) and n.name == '__init__').body
start = next(i for i, n in enumerate(body) if isinstance(n, ast.Assign) and ast.unparse(n.targets[0]) == 'self.ford_pscm_observer')
end = next(i for i, n in enumerate(body) if isinstance(n, ast.Assign) and ast.unparse(n.targets[0]) == 'self.ford_path')
if params is None:
params = SimpleNamespace(get_bool=lambda key: key == 'FordModelActionController')
controls = SimpleNamespace(CP=cp or car_params(), params=params)
environment = {'self': controls, 'FordFlags': FordFlags, 'FordPath': FordPath,
'FordPathController': FordPathController, 'FordPscmObserverPathController': FordPscmObserverPathController,
'FordModelActionController': FordModelActionController,
'select_model_action_controller': select_model_action_controller,
'cloudlog': SimpleNamespace(event=lambda *args, **kwargs: None)}
exec(compile(ast.Module(body=body[start:end+1], type_ignores=[]), str(filename), 'exec'), environment)
return controls
@pytest.mark.parametrize('candidate,observer', list(itertools.product((False, True), repeat=2)))
def test_actual_startup_priority(candidate, observer):
settings = {'FordModelActionController': candidate, 'FordPscmObserver': observer}
selected = startup(params=SimpleNamespace(get_bool=settings.__getitem__))
previous = FordPscmObserverPathController if observer else FordPathController
expected = FordModelActionController if candidate else previous
assert type(selected.ford_path_controller) is expected
assert selected.ford_model_action == candidate
assert selected.ford_path == FordPath()
@pytest.mark.parametrize('overrides', [{'brand': 'tesla'}, {'flags': 0}, {'carFingerprint': 'FORD_F_150_MK14'}])
@pytest.mark.parametrize('observer', [False, True])
def test_other_vehicles_keep_their_previous_selection(overrides, observer):
settings = {'FordModelActionController': False, 'FordPscmObserver': observer}
params = SimpleNamespace(get_bool=settings.__getitem__)
before = startup(car_params(**overrides), params)
settings['FordModelActionController'] = True
after = startup(car_params(**overrides), params)
assert type(after.ford_path_controller) is type(before.ford_path_controller)
assert not after.ford_model_action
@pytest.mark.parametrize('firmware', [[], [SimpleNamespace(ecu='eps', fwVersion=b'other')]])
def test_candidate_does_not_depend_on_eps_firmware_query(firmware):
assert isinstance(startup(car_params(carFw=firmware)).ford_path_controller, FordModelActionController)
@pytest.mark.parametrize('observer', [False, True])
def test_sunnylink_write_takes_effect_on_restart_and_restores_stored_selection(tmp_path, monkeypatch, observer):
from openpilot.sunnypilot.sunnylink import utils
params = Params(str(tmp_path))
monkeypatch.setattr(utils, 'Params', lambda: params)
assert params.get_default_value('FordModelActionController') is False
assert params.get_type('FordModelActionController') == ParamKeyType.BOOL
assert b'FordModelActionController' in params.all_keys(ParamKeyFlag.PERSISTENT)
assert b'FordModelActionController' in params.all_keys(ParamKeyFlag.BACKUP)
params.put_bool('FordPscmObserver', observer, block=True)
old = startup(params=params)
assert not isinstance(old.ford_path_controller, FordModelActionController)
utils.save_param_from_base64_encoded_string('FordModelActionController', base64.b64encode(b'true').decode())
enabled = startup(params=params)
assert isinstance(enabled.ford_path_controller, FordModelActionController)
assert not isinstance(old.ford_path_controller, FordModelActionController)
utils.save_param_from_base64_encoded_string('FordModelActionController', base64.b64encode(b'false').decode())
assert isinstance(enabled.ford_path_controller, FordModelActionController)
assert type(startup(params=params).ford_path_controller) is type(old.ford_path_controller)
assert params.get_bool('FordPscmObserver') == observer
def test_stored_retired_toggle_cannot_enable_the_candidate(tmp_path):
params = Params(str(tmp_path))
Path(params.get_param_path('FordVirtualAngleController')).write_text('1')
assert b'FordVirtualAngleController' not in params.all_keys()
assert params.get_bool('FordModelActionController') is False
assert type(startup(params=params).ford_path_controller) is FordPathController
params.put_bool('FordModelActionController', True, block=True)
params.clear_all(ParamKeyFlag.CLEAR_ON_MANAGER_START)
assert not Path(params.get_param_path('FordVirtualAngleController')).exists()
assert params.get_bool('FordModelActionController') is True
@@ -1,422 +0,0 @@
import math
from types import SimpleNamespace
import numpy as np
from openpilot.cereal import custom
from openpilot.selfdrive.car.helpers import convert_carControlSP
from openpilot.selfdrive.controls.lib.ford_path import (DBC_ANGLE, DBC_CURVATURE, DBC_OFFSET, FordPath, FordPathController,
FordPscmObserver, FordPscmObserverPathController, FordPscmState,
_bounded_feedback, _encode_path, _model_path, _predicted_pose,
_pscm_contributions, _relative_pose)
def _path(curvature: float, speed: float = 8.0):
t = np.linspace(0.0, 3.0, 61)
distance = speed * t
heading = curvature * distance
x = np.zeros_like(distance)
y = np.zeros_like(distance)
for i in range(1, len(distance)):
ds = distance[i] - distance[i - 1]
average_heading = 0.5 * (heading[i] + heading[i - 1])
x[i] = x[i - 1] + ds * math.cos(average_heading)
y[i] = y[i - 1] + ds * math.sin(average_heading)
return SimpleNamespace(
position=SimpleNamespace(t=t.tolist(), x=x.tolist(), y=y.tolist()),
orientation=SimpleNamespace(z=heading.tolist()),
)
def _changing_path(start_curvature: float, end_curvature: float, speed: float = 8.0):
t = np.linspace(0.0, 3.0, 61)
distance = speed * t
curvature = np.interp(distance, [distance[0], min(distance[-1], 7.0)], [start_curvature, end_curvature])
heading = np.zeros_like(distance)
x = np.zeros_like(distance)
y = np.zeros_like(distance)
for i in range(1, len(distance)):
ds = distance[i] - distance[i - 1]
heading[i] = heading[i - 1] + 0.5 * (curvature[i] + curvature[i - 1]) * ds
average_heading = 0.5 * (heading[i] + heading[i - 1])
x[i] = x[i - 1] + ds * math.cos(average_heading)
y[i] = y[i - 1] + ds * math.sin(average_heading)
return SimpleNamespace(
position=SimpleNamespace(t=t.tolist(), x=x.tolist(), y=y.tolist()),
orientation=SimpleNamespace(z=heading.tolist()),
)
def _command(model, desired_curvature: float, *, current_curvature: float = 0.0, v_ego: float = 8.0):
return FordPathController(dt=1.0).update(model, desired_curvature, current_curvature=current_curvature, v_ego=v_ego)
def _equivalent_curvature(command) -> float:
return 2.0 * command.path_offset / 7.0 ** 2 + 2.0 * command.path_angle / 7.0 + command.curvature
def test_gentle_path_uses_only_c2():
command = _command(_path(0.004, speed=20.0), 0.004, current_curvature=0.004, v_ego=20.0)
assert command.valid
assert command.path_offset == 0.0
assert command.path_angle == 0.0
assert np.isclose(command.curvature, 0.004, atol=1e-6)
assert command.curvature_rate == 0.0
def test_gentle_path_uses_only_c2_when_model_and_action_disagree():
command = _command(_path(0.005), 0.002, current_curvature=0.005)
assert command.path_offset == 0.0
assert command.path_angle == 0.0
assert np.isclose(command.curvature, 0.002, atol=1e-6)
def test_spatially_growing_path_adds_fast_pose_before_action_becomes_large():
controller = FordPathController(dt=1.0)
command = controller.update(_changing_path(0.0, 0.04), 0.012, current_curvature=0.0, v_ego=8.0)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
assert command.curvature < 0.012
assert command.curvature_rate == 0.0
def test_growing_model_pose_adds_authority_but_c3_is_never_transmitted():
constant = _command(_path(0.012), 0.012)
growing = _command(_changing_path(0.0, 0.04), 0.012)
assert _equivalent_curvature(growing) > _equivalent_curvature(constant)
assert constant.curvature_rate == 0.0
assert growing.curvature_rate == 0.0
def test_local_tracking_error_corrects_without_replacing_forward_pose():
model = _changing_path(0.0, 0.04)
local_curvature = 0.5 * 0.04 * 2.0 / 7.0
aligned = _command(model, 0.012, current_curvature=local_curvature)
under = _command(model, 0.012, current_curvature=0.0)
assert aligned.path_offset > 0.0
assert aligned.path_angle > 0.0
assert under.path_offset > aligned.path_offset
assert under.path_angle > aligned.path_angle
def test_large_maneuver_uses_fast_pose_and_zeros_c2():
command = _command(_path(0.04), 0.04)
assert command.path_offset > 0.5
assert command.path_angle > 0.2
assert command.curvature == 0.0
assert command.curvature_rate == 0.0
def test_model_pose_can_trigger_maneuver_when_action_is_late():
command = _command(_path(0.04), 0.002)
assert command.path_offset > 0.5
assert command.path_angle > 0.2
assert command.curvature == 0.0
def test_gentle_model_pose_does_not_replace_a_collapsed_action():
command = _command(_path(0.005), 0.0, current_curvature=0.005)
assert command.path_offset == 0.0
assert command.path_angle == 0.0
assert command.curvature == 0.0
def test_changing_gentle_curve_keeps_upstream_strength_c2():
command = _command(_changing_path(0.0, 0.008), 0.004, current_curvature=0.0)
assert np.isclose(command.curvature, 0.004)
assert command.path_offset == 0.0
assert command.path_angle == 0.0
def test_action_only_maneuver_cannot_invent_large_model_pose():
command = _command(_path(0.002), 0.04)
assert 0.0 < command.path_offset < 0.1
assert 0.0 < command.path_angle < 0.03
assert command.curvature == 0.0
def test_nearby_demands_blend_continuously_without_a_mode_threshold():
low = _command(_path(0.0119), 0.0119)
high = _command(_path(0.0121), 0.0121)
assert abs(high.path_offset - low.path_offset) < 0.05
assert abs(high.path_angle - low.path_angle) < 0.03
assert abs(high.curvature - low.curvature) < 0.001
def test_leaving_c2_normal_band_does_not_drop_total_authority():
normal = _command(_path(0.006), 0.006)
transition = _command(_path(0.0061), 0.0061)
assert transition.curvature <= normal.curvature
assert _equivalent_curvature(transition) >= _equivalent_curvature(normal)
def test_low_speed_still_uses_available_model_pose():
command = _command(_path(0.04, speed=2.0), 0.04, v_ego=2.0)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
def test_higher_speed_advances_predicted_pose_and_extends_heading_horizon():
model = _changing_path(0.0, 0.015, speed=20.0)
slow = _command(model, 0.012, v_ego=7.0)
fast = _command(model, 0.012, v_ego=20.0)
assert fast.path_offset > slow.path_offset
assert fast.path_angle > slow.path_angle
def test_short_model_uses_available_endpoint():
model = _path(0.04, speed=1.0)
command = _command(model, 0.04, v_ego=1.0)
assert command.valid
assert command.path_offset > 0.0
assert command.path_angle > 0.0
def test_turn_entry_coordinates_c2_release_with_fast_pose_attack():
controller = FordPathController(dt=0.01)
for _ in range(20):
assert controller.update(_path(0.004), 0.004, v_ego=8.0).curvature > 0.0
outputs = [controller.update(_path(0.04), 0.04, current_curvature=0.01, v_ego=8.0) for _ in range(100)]
assert 0.0 < outputs[0].curvature < 0.004
assert outputs[0].path_offset > 0.0
assert outputs[0].path_angle > 0.0
assert outputs[-1].curvature == 0.0
def test_turn_exit_allows_c2_to_take_over_while_fast_pose_drains():
controller = FordPathController(dt=0.01)
for _ in range(20):
controller.update(_path(0.04), 0.04, current_curvature=0.02, v_ego=8.0)
outputs = [controller.update(_path(0.004), 0.004, current_curvature=0.004, v_ego=8.0) for _ in range(100)]
assert 0.0 < outputs[0].curvature < 0.004
assert outputs[0].path_offset != 0.0 or outputs[0].path_angle != 0.0
assert outputs[-1].path_offset == 0.0
assert outputs[-1].path_angle == 0.0
def test_100hz_handoff_preserves_total_authority_without_entry_drop_or_exit_overshoot():
controller = FordPathController(dt=0.01)
normal = controller.update(_path(0.006), 0.006, current_curvature=0.006, v_ego=8.0)
entries = [controller.update(_path(0.04), 0.04, current_curvature=0.01, v_ego=8.0) for _ in range(100)]
entry_authority = np.asarray([_equivalent_curvature(command) for command in entries])
assert np.all(np.diff(entry_authority) >= -1e-9)
assert entry_authority[0] >= _equivalent_curvature(normal)
exits = [controller.update(_path(0.004), 0.004, current_curvature=0.004, v_ego=8.0) for _ in range(100)]
exit_authority = np.asarray([_equivalent_curvature(command) for command in exits])
assert np.all(np.diff(exit_authority) <= 1e-9)
assert np.all(exit_authority >= 0.004 - 1e-9)
def test_measured_tracking_error_closes_bidirectionally_without_abandoning_the_turn():
model = _path(0.04)
under = _command(model, 0.04, current_curvature=0.005)
on_target = _command(model, 0.04, current_curvature=0.04)
over = _command(model, 0.04, current_curvature=0.05)
assert under.path_offset > on_target.path_offset
assert under.path_angle > on_target.path_angle
assert 0.0 < over.path_offset < on_target.path_offset
assert 0.0 < over.path_angle < on_target.path_angle
def test_gentle_curve_does_not_add_fast_tracking_trim():
model = _path(0.004)
under = _command(model, 0.004, current_curvature=0.002)
on_target = _command(model, 0.004, current_curvature=0.004)
over = _command(model, 0.004, current_curvature=0.006)
assert under.path_offset == on_target.path_offset == over.path_offset == 0.0
assert under.path_angle == on_target.path_angle == over.path_angle == 0.0
assert np.allclose([under.curvature, on_target.curvature, over.curvature], 0.004, atol=2e-6)
def test_overshoot_trim_cannot_erase_a_modeled_turn():
model = _path(0.04)
on_target = _command(model, 0.04, current_curvature=0.04)
over = _command(model, 0.04, current_curvature=0.06)
assert over.path_offset > 0.95 * on_target.path_offset
assert over.path_angle > 0.9 * on_target.path_angle
def test_corrupt_measured_curvature_cannot_reverse_a_modeled_turn():
command = _command(_path(0.04), 0.04, current_curvature=0.5)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
assert command.curvature == 0.0
def test_feedback_preserves_half_lsb_feedforward_direction():
for feedforward, resolution in ((0.006, 0.01), (0.0004, 0.0005)):
result = feedforward + _bounded_feedback(feedforward, -1.0, resolution, 1.0)
assert result >= 0.5 * resolution
def test_recent_curvature_trend_advances_vehicle_pose_without_a_response_gain():
model = _model_path(_path(0.04))
assert model is not None
constant = _encode_path(model, 0.04, current_curvature=0.02, curvature_delta=0.0, v_ego=8.0)
rising = _encode_path(model, 0.04, current_curvature=0.02, curvature_delta=0.01, v_ego=8.0)
assert 0.0 < rising.path_offset < constant.path_offset
assert 0.0 < rising.path_angle < constant.path_angle
def test_model_path_exit_zeros_lingering_c2_and_countersteers():
command = _command(_path(0.0), 0.004, current_curvature=0.006)
assert command.path_offset <= 0.0
assert command.path_angle < 0.0
assert command.curvature == 0.0
def test_model_path_reversal_zeros_opposing_lingering_c2():
command = _command(_path(-0.004), 0.004, current_curvature=0.002)
assert command.path_offset < 0.0
assert command.path_angle < 0.0
assert command.curvature == 0.0
def test_s_turn_reverses_model_pose_without_slow_c2():
controller = FordPathController(dt=0.05)
for _ in range(10):
controller.update(_path(0.04), 0.04, v_ego=8.0)
outputs = [controller.update(_path(-0.04), -0.04, v_ego=8.0) for _ in range(10)]
assert all(command.curvature == 0.0 for command in outputs)
assert np.all(np.diff([command.path_offset for command in outputs]) < 0.0)
assert np.all(np.diff([command.path_angle for command in outputs]) < 0.0)
assert outputs[-1].path_offset < 0.0
assert outputs[-1].path_angle < 0.0
def test_output_limits_and_rates_are_bounded():
controller = FordPathController()
outputs = [controller.update(_path(0.2), 0.2, v_ego=8.0) for _ in range(100)]
assert all(DBC_OFFSET[0] <= command.path_offset <= DBC_OFFSET[1] for command in outputs)
assert all(DBC_ANGLE[0] <= command.path_angle <= DBC_ANGLE[1] for command in outputs)
assert all(DBC_CURVATURE[0] <= command.curvature <= DBC_CURVATURE[1] for command in outputs)
assert np.max(np.abs(np.diff([command.path_offset for command in outputs]))) <= 0.04 + 1e-9
assert np.max(np.abs(np.diff([command.path_angle for command in outputs]))) <= 0.01 + 1e-9
def test_clipped_path_angle_uses_available_offset_to_preserve_endpoint():
horizon = 7.0
for curvature, angle_limit in ((-0.1, DBC_ANGLE[0]), (0.1, DBC_ANGLE[1])):
model = _path(curvature)
command = _command(model, curvature, current_curvature=curvature, v_ego=horizon)
path = _model_path(model)
assert path is not None
advance = 0.1 * horizon
model_offset, model_angle = _relative_pose(advance + horizon, path,
_predicted_pose(advance, curvature, 0.0))
assert command.path_angle == angle_limit
assert np.isclose(command.path_offset + horizon * command.path_angle,
model_offset + horizon * model_angle)
def test_invalid_model_ramps_pose_to_zero_and_inactive_resets():
controller = FordPathController(dt=0.01)
for _ in range(20):
active = controller.update(_path(0.04), 0.04, v_ego=8.0)
invalid = controller.update(None, 0.0, v_ego=8.0)
assert invalid.valid
assert abs(invalid.path_offset) < abs(active.path_offset)
assert abs(invalid.path_angle) < abs(active.path_angle)
assert not controller.update(_path(0.0), 0.0, v_ego=8.0, active=False).valid
def test_sunnypilot_path_message_round_trip():
message = custom.CarControlSP.new_message()
message.fordLateralPath.pathOffset = 0.3
message.fordLateralPath.pathAngle = -0.2
message.fordLateralPath.curvature = 0.008
message.fordLateralPath.curvatureRate = -0.0004
message.fordLateralPath.valid = True
path = convert_carControlSP(message.as_reader()).fordLateralPath
assert np.isclose(path.pathOffset, 0.3)
assert np.isclose(path.pathAngle, -0.2)
assert np.isclose(path.curvature, 0.008)
assert np.isclose(path.curvatureRate, -0.0004)
assert path.valid
def test_pscm_observer_mirrors_exact_250hz_slew_and_c3_target():
observer = FordPscmObserver()
observer.set_command(FordPath(True, 1.0, 0.5, 0.0, 0.001))
observer.advance(1.0)
assert np.isclose(observer.state.path_offset, 1.0)
assert np.isclose(observer.state.path_angle, 0.100006103515625)
assert np.isclose(observer.state.curvature, 0.0030059814453125)
def test_pscm_observer_tracks_wire_quantized_commands():
observer = FordPscmObserver()
observer.set_command(FordPath(True, 0.006, 0.0004, 0.000011, 0.0))
assert observer.command.path_offset == 0.01
assert observer.command.path_angle == 0.0005
assert observer.command.curvature == 0.00002
def test_pscm_c2_contribution_is_speed_scheduled():
state = FordPscmObserver().state
state = type(state)(curvature=0.004)
low = _pscm_contributions(state, 5.0)[2]
high = _pscm_contributions(state, 20.0)[2]
assert high > low * 10.0
def test_pscm_observer_fills_missing_gentle_c2_with_fast_fields():
controller = FordPscmObserverPathController(dt=0.01)
command = controller.update(_path(0.004, speed=20.0), 0.004, current_curvature=0.004,
v_ego=20.0, v_ego_raw=20.0)
assert command.path_offset > 0.0
assert command.path_angle > 0.0
assert command.curvature > 0.0
def test_pscm_observer_uses_c0_only_after_c1_reaches_its_effective_limit():
controller = FordPscmObserverPathController(dt=0.01)
small = controller._command_for_state(FordPath(True, 0.2, 0.0, 0.0, 0.0), 8.0)
large = controller._command_for_state(FordPath(True, 1.0, 0.5, 0.0, 0.0), 8.0)
assert small.path_offset == 0.0
assert small.path_angle > 0.0
assert large.path_offset > 0.0
assert large.path_angle == 0.349609375 / 10.0
def test_pscm_observer_preserves_c2_residual_across_c0_c1_headroom():
controller = FordPscmObserverPathController(dt=0.01)
target = FordPath(True, 0.0, 0.0, 0.004, 0.0)
command = controller._command_for_state(target, 20.0)
target_contribution = sum(_pscm_contributions(FordPscmState(curvature=target.curvature), 20.0))
command_contributions = _pscm_contributions(FordPscmState(command.path_offset, command.path_angle), 20.0)
assert np.isclose(sum(command_contributions), target_contribution)
controller.observer.state = FordPscmState(curvature=0.004)
unwind = controller._command_for_state(FordPath(valid=True), 20.0)
unwind_contributions = _pscm_contributions(FordPscmState(unwind.path_offset, unwind.path_angle), 20.0)
lingering_c2 = _pscm_contributions(controller.observer.state, 20.0)[2]
assert np.isclose(sum(unwind_contributions) + lingering_c2, 0.0)
def test_pscm_observer_unloads_fast_residual_as_c2_loads():
controller = FordPscmObserverPathController(dt=0.01)
outputs = [controller.update(_path(0.004, speed=20.0), 0.004, current_curvature=0.004,
v_ego=20.0, v_ego_raw=20.0) for _ in range(200)]
assert outputs[0].path_angle > outputs[-1].path_angle >= 0.0
assert controller.observer.state.curvature > 0.003
def test_pscm_observer_counters_lingering_c2_during_model_exit():
controller = FordPscmObserverPathController(dt=0.01)
for _ in range(200):
controller.update(_path(0.004, speed=20.0), 0.004, current_curvature=0.004,
v_ego=20.0, v_ego_raw=20.0)
command = controller.update(_path(0.0, speed=20.0), 0.0, current_curvature=0.004,
v_ego=20.0, v_ego_raw=20.0)
assert command.path_angle < 0.0
assert command.curvature < controller.observer.state.curvature
def test_pscm_observer_avoids_ineffective_c0_c1_windup():
controller = FordPscmObserverPathController(dt=1.0)
command = controller.update(_path(0.2), 0.2, v_ego=8.0, v_ego_raw=8.0)
assert abs(command.path_offset) <= 1.0
assert abs(command.path_angle) <= 0.349609375 / 10.0
+54 -51
View File
@@ -7,9 +7,14 @@ from openpilot.common.file_chunker import chunk_file, get_chunk_targets, get_exi
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE
from openpilot.selfdrive.modeld.constants import ModelConstants
from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, chestnut_present, modeld_pkl_path
from openpilot.selfdrive.modeld.helpers import TG_INPUT_DEVICES_PATH, usbgpu_present, modeld_pkl_path
CAMERA_CONFIGS = [
(_ar_ox_fisheye.width, _ar_ox_fisheye.height), # tici: 1928x1208
(_os_fisheye.width, _os_fisheye.height), # mici: 1344x760
]
Import('env', 'arch')
chunker_file = File("#openpilot/common/file_chunker.py")
lenv = env.Clone()
@@ -19,32 +24,30 @@ tinygrad_files = ["#"+x for x in glob.glob(env.Dir("#tinygrad_repo").relpath + "
if 'pycache' not in x and os.path.isfile(os.path.join(tinygrad_root, x))]
def estimate_pickle_max_size(onnx_size):
# QCOM programs for models with spatial recurrent features can approach 2x
# the ONNX size. Overestimating only adds an empty trailing chunk.
return 2.0 * onnx_size + 10 * 1024 * 1024
return 1.2 * onnx_size + 10 * 1024 * 1024 # 20% + 10MB is plenty
if arch == 'comma_arm64':
from openpilot.common.hardware import HARDWARE
camera = _os_fisheye if HARDWARE.get_device_type() == "mici" else _ar_ox_fisheye
camera_configs = [(camera.width, camera.height)]
tg_backend = 'QCOM'
tg_flags = f'DEV={tg_backend} IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1'
else:
camera_configs = [(c.width, c.height) for c in (_ar_ox_fisheye, _os_fisheye)]
tg_backend = 'CPU'
tg_flags = f'DEV=CPU' if arch == 'Darwin' else 'DEV=CPU:LLVM'
tg_devices = { # which device to put jit inputs to at runtime
'openpilot.selfdrive.modeld.modeld': {
'default': {'WARP_DEV': tg_backend, 'QUEUE_DEV': tg_backend},
'usbgpu': {'WARP_DEV': tg_backend, 'QUEUE_DEV': 'AMD'}
},
'openpilot.selfdrive.modeld.dmonitoringmodeld': {
'default': {'DEV': tg_backend}
},
}
CHESTNUT = chestnut_present()
if CHESTNUT:
chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_OCCUPANCY_OPT=1'
USBGPU = usbgpu_present()
if USBGPU:
usbgpu_tg_flags = f'DEBUG=2 DEV=USB+AMD:LLVM WARP_DEV={tg_backend} FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2'
# the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it
chestnut_lock = File("models/.chestnut.lock").abspath
usbgpu_lock = File("models/.usb_gpu.lock").abspath
def write_tg_devices(target, source, env):
with open(str(target[0]), "w") as f:
@@ -70,44 +73,44 @@ compile_modeld_script = [
model_w, model_h = MEDMODEL_INPUT_SIZE
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
if not os.getenv('SKIP_TINYGRAD_COMPILE'):
for chestnut in [False, True] if CHESTNUT else [False]:
target_pkl_path = File(modeld_pkl_path(chestnut)).abspath
file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in camera_configs)
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} '
f'--output {target_pkl_path} --frame-skip {frame_skip}')
onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps)
chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum))
def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping big model build")
return
if ret := env.Execute(command):
return ret
chunk_file(pkl, chunks)
def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets):
chunk_file(pkl, chunks)
actions = Action(do_compile, " [CHESTNUT] $TARGET") if chestnut else [cmd, Action(do_chunk, " [CHUNK] $TARGET")]
node = lenv.Command(
chunk_targets,
tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(camera_res_args), Value(chunk_targets), chunker_file],
actions,
)
if chestnut:
lenv.SideEffect(chestnut_lock, node)
for usbgpu in [False, True] if USBGPU else [False]:
target_pkl_path = File(modeld_pkl_path(usbgpu)).abspath
# BIG_INTO_SMALL=1 builds the default target from the big model, e.g. to test it without a USB GPU
file_prefix, cmd_flags = ('big_', usbgpu_tg_flags) if usbgpu else ('big_' if os.getenv('BIG_INTO_SMALL') else '', tg_flags)
driving_onnx_deps = get_existing_chunks(File(f"models/{file_prefix}driving_supercombo.onnx").abspath)
camera_res_args = ' '.join(f'{cw}x{ch}' for cw, ch in CAMERA_CONFIGS)
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 {modeld_dir}/compile_modeld.py '
f'--model-size {model_w}x{model_h} '
f'--camera-resolutions {camera_res_args} '
f'--onnx {File(f"models/{file_prefix}driving_supercombo.onnx").abspath} '
f'--output {target_pkl_path} --frame-skip {frame_skip}')
onnx_sizes_sum = sum(os.path.getsize(f) for f in driving_onnx_deps)
chunk_targets = get_chunk_targets(target_pkl_path, estimate_pickle_max_size(onnx_sizes_sum))
def do_compile(target, source, env, command=cmd, pkl=target_pkl_path, chunks=chunk_targets):
from openpilot.system.hardware.chestnut.flash import link_up
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
for _ in range(10):
if link_up():
break
time.sleep(1)
else:
print("Chestnut not ready, skipping big model build")
return
if ret := env.Execute(command):
return ret
chunk_file(pkl, chunks)
def do_chunk(target, source, env, pkl=target_pkl_path, chunks=chunk_targets):
chunk_file(pkl, chunks)
actions = Action(do_compile, " [USBGPU] $TARGET") if usbgpu else [cmd, Action(do_chunk, " [CHUNK] $TARGET")]
node = lenv.Command(
chunk_targets,
tinygrad_files + compile_modeld_script + driving_onnx_deps + [Value(chunk_targets), chunker_file],
actions,
)
if usbgpu:
lenv.SideEffect(usbgpu_lock, node)
# get model metadata
fn = File(f"models/dmonitoring_model").abspath
@@ -117,7 +120,7 @@ lenv.Command(fn + "_metadata.pkl", [fn + ".onnx"] + tinygrad_files + script_file
dm_w, dm_h = DM_INPUT_SIZE
compile_dm_warp_script = [File(f"{modeld_dir}/compile_dm_warp.py")]
for cam_w, cam_h in camera_configs:
for cam_w, cam_h in CAMERA_CONFIGS:
dm_pkl_path = File(f"models/dm_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
cmd = (f'{tg_flags} {mac_brew_string} python3 {modeld_dir}/compile_dm_warp.py '
f'--camera-resolution {cam_w}x{cam_h} --warp-to {dm_w}x{dm_h} '
+73 -86
View File
@@ -37,12 +37,17 @@ from tinygrad.engine.jit import TinyJit
NV12Frame = namedtuple("NV12Frame", ['width', 'height', 'stride', 'y_height', 'uv_height', 'size'])
MODELD_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs']
WARP_INPUTS = ['tfm', 'big_tfm']
POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs']
UV_SCALE_MATRIX = np.array([[0.5, 0, 0], [0, 0.5, 0], [0, 0, 1]], dtype=np.float32)
UV_SCALE_MATRIX_INV = np.linalg.inv(UV_SCALE_MATRIX)
WARP_DEV = os.getenv('WARP_DEV')
def nv12_copy_size(stride: int, y_height: int, uv_height: int) -> int:
# Retain the padded Y and UV plane storage, but skip the trailing kernel/guard allocation.
return stride * (y_height + uv_height)
def make_random_images(keys, shape, device=None):
return {k: Tensor.randint(shape, low=0, high=256, dtype='uint8', device=device).realize() for k in keys}
def warp_perspective_tinygrad(src_flat, M_inv, dst_shape, src_shape, stride_pad, border_fill_val=None):
@@ -94,7 +99,7 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h):
def frame_prepare_tinygrad(input_frame, M_inv):
# UV_SCALE @ M_inv @ UV_SCALE_INV simplifies to elementwise scaling
M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=Device.DEFAULT)
M_inv_uv = M_inv * Tensor([[1.0, 1.0, 0.5], [1.0, 1.0, 0.5], [2.0, 2.0, 1.0]], device=WARP_DEV)
# deinterleave NV12 UV plane (UVUV... -> separate U, V)
uv = input_frame[uv_offset:uv_offset + uv_height * stride].reshape(uv_height, stride)
with Context(SPLIT_REDUCEOP=0):
@@ -113,43 +118,49 @@ def make_frame_prepare(nv12: NV12Frame, model_w, model_h):
return frame_prepare_tinygrad
def make_warp_input_queues(vision_input_shapes, frame_skip, device):
img = vision_input_shapes['img'] # (1, 12, 128, 256)
n_frames = img[1] // 6
img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3])
npy = {
'tfm': np.zeros((3, 3), dtype=np.float32),
'big_tfm': np.zeros((3, 3), dtype=np.float32),
}
input_queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
**{k: Tensor(v, device='NPY').realize() for k, v in npy.items()},
}
return input_queues, npy
def get_policy_npy_shapes(input_shapes):
dp = input_shapes['desire_pulse'] # (1, 25, 8)
tc = input_shapes['traffic_convention'] # (1, 2)
at = input_shapes['action_t'] # (1, 2)
fb = input_shapes['features_buffer'] # (1, T-1, ...) e.g. (1, 24, 32, 512) with spatial features
feat_dim = math.prod(fb[2:])
fb = input_shapes['features_buffer'] # (1, 24, 512)
# TODO prev_feat shouldn't exist and be handled inside the JIT, but corrupt on QCOM for now
shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], feat_dim)}
shapes = {'desire': (dp[2],), 'traffic_convention': tuple(tc), 'action_t': tuple(at), 'prev_feat': (fb[0], fb[2])}
return shapes, [math.prod(s) for s in shapes.values()]
def make_input_queues(input_shapes, frame_skip, device, frame_copy_size):
img = input_shapes['img'] # (1, 12, 128, 256)
fb = input_shapes['features_buffer'] # (1, T-1, ...), past features only; the model appends the current frame's feature
feat_dim = math.prod(fb[2:])
dp = input_shapes['desire_pulse'] # (1, 25, 8)
n_frames = img[1] // 6
img_buf_shape = (frame_skip * (n_frames - 1) + 1, 6, img[2], img[3])
def make_input_queues(input_shapes, frame_skip, device):
input_queues, npy = make_warp_input_queues(input_shapes, frame_skip, device)
policy_shapes, _ = get_policy_npy_shapes(input_shapes)
shapes = {'tfm': (3, 3), 'big_tfm': (3, 3)} | policy_shapes
sizes = [math.prod(s) for s in shapes.values()]
packed_npy_size = sum(sizes) * np.dtype(np.float32).itemsize
packed_input = np.zeros(packed_npy_size + 2 * frame_copy_size, dtype=np.uint8)
packed_npy_inputs = packed_input[:packed_npy_size].view(np.float32)
frames = packed_input[packed_npy_size:]
frame_views = {'img': frames[:frame_copy_size], 'big_img': frames[frame_copy_size:]}
fb = input_shapes['features_buffer'] # (1, 24, 512), past features only; the model appends the current frame's feature
dp = input_shapes['desire_pulse'] # (1, 25, 8)
shapes, sizes = get_policy_npy_shapes(input_shapes)
packed_npy_inputs = np.zeros(sum(sizes), dtype=np.float32)
# views into the packed inputs, to be refilled at runtime
npy = {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)}
input_queues = {
'img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], feat_dim), dtype=np.float32), device=device).contiguous().realize(),
npy.update({k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed_npy_inputs, np.cumsum(sizes[:-1])), strict=True)})
input_queues.update({
'feat_q': Tensor(np.zeros((frame_skip * fb[1], fb[0], fb[2]), dtype=np.float32), device=device).contiguous().realize(),
'desire_q': Tensor(np.zeros((frame_skip * dp[1], dp[0], dp[2]), dtype=np.float32), device=device).contiguous().realize(),
'packed_npy_inputs': Tensor(packed_input, device='NPY').realize(),
}
return input_queues, npy, frame_views
'packed_npy_inputs': Tensor(packed_npy_inputs, device='NPY').realize(),
})
return input_queues, npy
def shift_and_sample(buf, new_val, sample_fn):
@@ -165,15 +176,13 @@ def sample_desire(buf, frame_skip):
return buf.reshape(-1, frame_skip, *buf.shape[1:]).max(1).flatten(0, 1).unsqueeze(0)
def make_warp(nv12, model_w, model_h):
def make_warp(nv12, model_w, model_h, frame_skip):
frame_prepare = make_frame_prepare(nv12, model_w, model_h)
def warp(tfm, big_tfm, frame, big_frame):
tfm = tfm.to(Device.DEFAULT)
big_tfm = big_tfm.to(Device.DEFAULT)
frame = frame.to(Device.DEFAULT)
big_frame = big_frame.to(Device.DEFAULT)
Tensor.realize(tfm, big_tfm, frame, big_frame)
tfm = tfm.to(WARP_DEV)
big_tfm = big_tfm.to(WARP_DEV)
Tensor.realize(tfm, big_tfm)
warped_frame = frame_prepare(frame, tfm).unsqueeze(0)
warped_big_frame = frame_prepare(big_frame, big_tfm).unsqueeze(0)
@@ -186,10 +195,10 @@ def make_run_policy(model_runner, model_metadata, frame_skip):
sample_desire_fn = partial(sample_desire, frame_skip=frame_skip)
sample_skip_fn = partial(sample_skip, frame_skip=frame_skip)
npy_shapes, npy_sizes = get_policy_npy_shapes(model_metadata['input_shapes'])
model_input_dtypes = {name: spec.dtype for name, spec in model_runner.graph_inputs.items()}
def run_policy(warped, img_q, big_img_q, feat_q, desire_q, packed_npy_inputs):
packed_npy_inputs = packed_npy_inputs.to(Device.DEFAULT)
warped = warped.to(Device.DEFAULT)
Tensor.realize(packed_npy_inputs, warped)
img = shift_and_sample(img_q, warped[0:1], sample_skip_fn)
@@ -202,50 +211,33 @@ def make_run_policy(model_runner, model_metadata, frame_skip):
inputs = {
'img': img,
'big_img': big_img,
'features_buffer': feat_buf.reshape(model_metadata['input_shapes']['features_buffer']),
'features_buffer': feat_buf,
'desire_pulse': desire_buf,
'traffic_convention': traffic_convention,
'action_t': action_t,
}
inputs = {name: value.cast(model_input_dtypes[name]) for name, value in inputs.items()}
out = next(iter(model_runner(inputs).values())).cast('float32')
return out,
return run_policy
def make_run_model(warp, run_policy, model_metadata, frame_copy_size):
_, policy_sizes = get_policy_npy_shapes(model_metadata['input_shapes'])
packed_npy_size = (18 + sum(policy_sizes)) * np.dtype(np.float32).itemsize
def run_model(img_q, big_img_q, feat_q, desire_q, packed_npy_inputs):
packed_input = packed_npy_inputs.to(Device.DEFAULT)
Tensor.realize(packed_input)
packed_npy_inputs = packed_input[:packed_npy_size].bitcast('float32')
frame = packed_input[packed_npy_size:packed_npy_size + frame_copy_size]
big_frame = packed_input[packed_npy_size + frame_copy_size:]
tfm, big_tfm, policy_inputs = packed_npy_inputs.split([9, 9, sum(policy_sizes)])
warped = warp(tfm.reshape(3, 3), big_tfm.reshape(3, 3), frame, big_frame)
return run_policy(warped, img_q, big_img_q, feat_q, desire_q, policy_inputs)
return run_model
def compile_jit(jit, input_keys, make_queues, benchmark_runs):
if benchmark_runs < 1:
raise ValueError("benchmark_runs must be at least 1")
def compile_jit(jit, make_random_inputs, input_keys, make_queues):
SEED = 42
def random_inputs_run(fn, seed, n_runs, test_val=None, test_buffers=None, expect_match=True):
input_queues, npy, frame_views = make_queues(Device.DEFAULT)
def random_inputs_run(fn, seed, test_val=None, test_buffers=None, expect_match=True):
input_queues, npy = make_queues(Device.DEFAULT)
rng = np.random.default_rng(seed)
Tensor.manual_seed(seed)
testing = test_val is not None or test_buffers is not None
n_runs = 1 if testing else 3
for i in range(n_runs):
for v in npy.values():
v[:] = rng.standard_normal(v.shape).astype(v.dtype)
for v in frame_views.values():
v[:] = rng.integers(0, 256, size=v.shape, dtype=np.uint8)
Device.default.synchronize()
random_inputs = make_random_inputs()
st = time.perf_counter()
outs = fn(**{k: input_queues[k] for k in input_keys})
outs = fn(**{k: input_queues[k] for k in input_keys}, **random_inputs)
mt = time.perf_counter()
Device.default.synchronize()
et = time.perf_counter()
@@ -264,15 +256,14 @@ def compile_jit(jit, input_keys, make_queues, benchmark_runs):
return val, buffers
print('capture + replay')
test_val, test_buffers = random_inputs_run(jit, SEED, 3)
print(f'pickle round trip ({benchmark_runs} runs per seed)')
test_val, test_buffers = random_inputs_run(jit, SEED)
print('pickle round trip')
with tempfile.TemporaryFile(dir=".") as f:
dump_oob(jit, f)
f.seek(0)
loaded_jit = load_oob(f)
random_inputs_run(loaded_jit, SEED, benchmark_runs, test_val, test_buffers, expect_match=True)
random_inputs_run(loaded_jit, SEED+1, benchmark_runs, test_val, test_buffers, expect_match=False)
# Keep the original so per-resolution JITs share model weight buffers in the final pickle.
jit = load_oob(f)
random_inputs_run(jit, SEED, test_val, test_buffers, expect_match=True)
random_inputs_run(jit, SEED+1, test_val, test_buffers, expect_match=False)
return jit
@@ -301,31 +292,27 @@ if __name__ == "__main__":
p.add_argument('--onnx', required=True)
p.add_argument('--output', required=True)
p.add_argument('--frame-skip', type=int, required=True)
p.add_argument('--benchmark-runs', type=int, default=1,
help='timed loaded-JIT runs for each correctness seed')
args = p.parse_args()
model_path = read_file_chunked_to_disk(args.onnx)
model_w, model_h = args.model_size
model_runner = OnnxRunner(model_path)
out = {
'metadata': make_metadata_dict(model_path),
'input_devices': {'model': Device.DEFAULT},
'run_model': {},
}
out = {'metadata': make_metadata_dict(model_path)}
run_policy = make_run_policy(model_runner, out['metadata'], args.frame_skip)
run_policy_jit = TinyJit(make_run_policy(model_runner, out['metadata'], args.frame_skip), prune=True)
make_policy_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip)
make_random_model_inputs = partial(make_random_images, keys=['warped'], shape=(2, 6, *out['metadata']['input_shapes']['img'][2:]), device=WARP_DEV)
out['run_policy'] = compile_jit(run_policy_jit, make_random_model_inputs, POLICY_INPUTS,
make_policy_queues)
for cam_w, cam_h in args.camera_resolutions:
nv12 = NV12Frame(cam_w, cam_h, *get_nv12_info(cam_w, cam_h))
frame_copy_size = nv12_copy_size(nv12.stride, nv12.y_height, nv12.uv_height)
make_model_queues = partial(make_input_queues, out['metadata']['input_shapes'], args.frame_skip,
frame_copy_size=frame_copy_size)
warp = make_warp(nv12, model_w, model_h)
run_model_jit = TinyJit(make_run_model(warp, run_policy, out['metadata'], frame_copy_size), prune=True)
out['run_model'][(cam_w,cam_h)] = compile_jit(run_model_jit, MODELD_INPUTS, make_model_queues,
args.benchmark_runs)
make_random_warp_inputs = partial(make_random_images, keys=['frame', 'big_frame'], shape=nv12.size, device=WARP_DEV)
warp = TinyJit(make_warp(nv12, model_w, model_h, args.frame_skip), prune=True)
make_warp_queues = partial(make_warp_input_queues, out['metadata']['input_shapes'], args.frame_skip)
out[(cam_w,cam_h)] = compile_jit(warp, make_random_warp_inputs, WARP_INPUTS, make_warp_queues)
with open(args.output, "wb") as f:
dump_oob(out, f)
@@ -29,7 +29,7 @@ class ModelState:
output: np.ndarray
def __init__(self, cam_w: int, cam_h: int):
self.DEV = get_tg_input_devices(PROCESS_NAME, chestnut=False)['DEV']
self.DEV = get_tg_input_devices(PROCESS_NAME, usbgpu=False)['DEV']
with open(METADATA_PATH, 'rb') as f:
model_metadata = pickle.load(f)
self.input_shapes = model_metadata['input_shapes']
@@ -64,7 +64,6 @@ def fill_driving_model_data(msg: capnp._DynamicStructBuilder, modelv2_send: capn
driving_model_data.frameIdExtra = modelV2.frameIdExtra
driving_model_data.frameDropPerc = modelV2.frameDropPerc
driving_model_data.modelExecutionTime = modelV2.modelExecutionTime
driving_model_data.big = modelV2.big
driving_model_data.action = modelV2.action
driving_model_data.meta.laneChangeState = modelV2.meta.laneChangeState
driving_model_data.meta.laneChangeDirection = modelV2.meta.laneChangeDirection
+9 -15
View File
@@ -7,20 +7,18 @@ import tempfile
from pathlib import Path
from openpilot.common.file_chunker import get_manifest_path
from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id
from openpilot.common.hardware.usb import CHESTNUT_FW_VERSION, CHESTNUT_USB_IDS, USB_DEVICES_PATH
MODELS_DIR = Path(__file__).resolve().parent / 'models'
TG_INPUT_DEVICES_PATH = MODELS_DIR / 'tg_input_devices.json'
CHESTNUT_POWERED_VOLTAGE = 5000
CHESTNUT_PCIE_READY = 0x78
def get_tg_input_devices(process_name: str, chestnut: bool):
def get_tg_input_devices(process_name: str, usbgpu: bool):
with open(TG_INPUT_DEVICES_PATH) as f:
return json.load(f)[process_name]['default' if not chestnut else 'chestnut']
return json.load(f)[process_name]['default' if not usbgpu else 'usbgpu']
def modeld_pkl_path(chestnut: bool):
prefix = 'big_' if chestnut else ''
def modeld_pkl_path(usbgpu: bool):
prefix = 'big_' if usbgpu else ''
return MODELS_DIR / f'{prefix}driving_tinygrad.pkl'
def dump_oob(obj, f):
@@ -47,20 +45,16 @@ def load_oob(f):
yield pb
return pickle.load(io.BytesIO(opcodes), buffers=buffers())
def chestnut_present() -> bool:
def usbgpu_present() -> bool:
for d in USB_DEVICES_PATH.glob("*"):
try:
usb_id = (int((d / "idVendor").read_text(), 16), int((d / "idProduct").read_text(), 16))
product = (d / "product").read_text().strip()
if is_chestnut_usb_id(*usb_id) and product == CHESTNUT_USB_PRODUCT:
if usb_id in CHESTNUT_USB_IDS and product == f"custom {CHESTNUT_FW_VERSION}-CLEAN":
return True
except Exception:
pass
return False
def chestnut_compiled() -> bool:
return Path(get_manifest_path(modeld_pkl_path(chestnut=True))).is_file()
def chestnut_ready(state) -> bool:
return state.supplyVoltage >= CHESTNUT_POWERED_VOLTAGE and not state.supplyFault and state.pcieLtssm == CHESTNUT_PCIE_READY
def usbgpu_compiled() -> bool:
return Path(get_manifest_path(modeld_pkl_path(usbgpu=True))).is_file()
+66 -105
View File
@@ -1,11 +1,9 @@
#!/usr/bin/env python3
from collections.abc import Callable
import ctypes
from functools import cached_property
import os
os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom
os.environ['GMMU'] = '0' # for usbgpu fast loading, noop for qcom
from tinygrad.tensor import Tensor
from tinygrad.device import Device
import usb1
import struct
import threading
import time
@@ -28,17 +26,17 @@ from openpilot.common.transformations.model import get_warp_matrix
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper
from openpilot.selfdrive.controls.lib.drive_helpers import get_accel_from_plan, should_stop, smooth_value, get_curvature_from_plan
from openpilot.selfdrive.modeld.parse_model_outputs import Parser
from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, nv12_copy_size, MODELD_INPUTS
from openpilot.selfdrive.modeld.compile_modeld import make_input_queues, WARP_INPUTS, POLICY_INPUTS
from openpilot.selfdrive.modeld.fill_model_msg import fill_model_msg, fill_driving_model_data, fill_pose_msg, PublishState
from openpilot.common.file_chunker import open_file_chunked
from openpilot.common.hardware.usb import CHESTNUT_USB_IDS
from openpilot.selfdrive.modeld.constants import ModelConstants, Plan
from openpilot.selfdrive.modeld.helpers import chestnut_present, chestnut_compiled, chestnut_ready, modeld_pkl_path, load_oob
from openpilot.selfdrive.modeld.helpers import usbgpu_present, usbgpu_compiled, modeld_pkl_path, get_tg_input_devices, load_oob
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
PROCESS_NAME = "openpilot.selfdrive.modeld.modeld"
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
LAT_SMOOTH_SECONDS = 0.0
@@ -83,37 +81,6 @@ class ChestnutState:
self.valid = True
self.sends = 0
self.metrics = {}
self._asm_usb = None
def _close_asm_usb(self) -> None:
if self._asm_usb is not None:
self._asm_usb.close()
self._asm_usb = None
def _open_asm_usb(self):
context = usb1.USBContext()
for vendor_id, product_id in CHESTNUT_USB_IDS:
if (handle := context.openByVendorIDAndProductID(vendor_id, product_id, skip_on_error=True)) is not None:
return handle
context.close()
def _read_ina(self) -> tuple[int, int, bool]:
if "AMD" in Device._opened_devices and self._asm_usb is None:
try:
raw = Device["AMD"].iface.pci_dev.usb.usb.control_read(0xC0, 5)
return struct.unpack('<Hh?', bytes(raw))
except Exception:
pass
if self._asm_usb is None:
self._asm_usb = self._open_asm_usb()
if self._asm_usb is None:
raise usb1.USBErrorNoDevice
try:
raw = self._asm_usb.controlRead(0xC0, 0xC0, 0, 0, 5, timeout=100)
except usb1.USBError:
self._close_asm_usb()
raise
return struct.unpack('<Hh?', bytes(raw))
@cached_property
def power_limit(self) -> int:
@@ -127,10 +94,8 @@ class ChestnutState:
if self.big and "AMD" in Device._opened_devices and self.sends % 100 == 1:
try:
smu = Device["AMD"].iface.dev_impl.smu
metrics_t = smu.smu_mod.SmuMetricsExternal_t
smu._send_msg(smu.smu_mod.PPSMC_MSG_TransferTableSmu2Dram, smu.smu_mod.TABLE_SMU_METRICS, timeout=100)
metrics_buf = bytearray(smu.adev.vram.view(smu.driver_table_paddr, ctypes.sizeof(metrics_t))[:])
metrics = metrics_t.from_buffer(metrics_buf).SmuMetrics
metrics = smu.read_table(smu.smu_mod.SmuMetricsExternal_t, smu.smu_mod.TABLE_SMU_METRICS).SmuMetrics
self.metrics = {'tempC': metrics.AvgTemperature[smu.smu_mod.TEMP_HOTSPOT],
'memoryTempC': metrics.AvgTemperature[smu.smu_mod.TEMP_MEM],
'powerDrawW': metrics.AverageSocketPower,
@@ -149,15 +114,13 @@ class ChestnutState:
setattr(state, k, v)
asm_valid = False
try:
# ASM runs on USB-C power, these still read without a gpu
state.supplyVoltage, state.supplyCurrent, state.supplyFault = self._read_ina()
asm_valid = True
except Exception:
pass
if "AMD" in Device._opened_devices:
try:
state.pcieLtssm = Device["AMD"].iface.pci_dev.usb.read(0xB450, 1)[0]
# ASM runs on USB-C power, these still read without a gpu
asm = Device["AMD"].iface.pci_dev.usb
state.pcieLtssm = asm.read(0xB450, 1)[0]
state.supplyVoltage, state.supplyCurrent = struct.unpack('<Hh', bytes(asm.usb.control_read(0xC0, 5))[:4])
asm_valid = True
except Exception:
pass
@@ -178,34 +141,42 @@ class FrameMeta:
class ModelState(ModelStateBase):
prev_desire: np.ndarray # for tracking the rising edge of the pulse
def __init__(self, cam_w: int, cam_h: int, chestnut: bool):
def __init__(self, cam_w: int, cam_h: int, usbgpu: bool):
ModelStateBase.__init__(self)
jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut)))
input_devices = jits['input_devices']
self.model_device = input_devices['model']
input_devices = get_tg_input_devices(PROCESS_NAME, usbgpu)
self.WARP_DEV, self.QUEUE_DEV = input_devices['WARP_DEV'], input_devices['QUEUE_DEV']
jits = load_oob(open_file_chunked(modeld_pkl_path(usbgpu)))
metadata = jits['metadata']
self.input_shapes = metadata['input_shapes']
self.vision_input_names = [k for k in self.input_shapes if 'img' in k]
self.output_slices = metadata['output_slices']
self.prev_desire = np.zeros(ModelConstants.DESIRE_LEN, dtype=np.float32)
self.chestnut = chestnut
self.usbgpu = usbgpu
self.frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
self.frame_copy_size = nv12_copy_size(*get_nv12_info(cam_w, cam_h)[:3])
self.input_queues, self.npy, self.frame_views = make_input_queues(
self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size)
self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV)
self.full_frames: dict[str, Tensor] = {}
self._blob_cache: dict[tuple[str, int], Tensor] = {}
self.parser = Parser()
self.run_model = jits['run_model'][(cam_w,cam_h)]
self.frame_buf_params = {k: get_nv12_info(cam_w, cam_h) for k in ('img', 'big_img')}
self.run_policy = jits['run_policy']
self.warp = jits[(cam_w,cam_h)]
def slice_outputs(self, model_outputs: np.ndarray, output_slices: dict[str, slice]) -> dict[str, np.ndarray]:
parsed_model_outputs = {k: model_outputs[np.newaxis, v] for k,v in output_slices.items()}
return parsed_model_outputs
def run(self, bufs: dict[str, VisionBuf], transforms: dict[str, np.ndarray],
inputs: dict[str, np.ndarray], after_enqueue: Callable[[], None] | None = None) -> dict[str, np.ndarray]:
for key, buf in bufs.items():
np.copyto(self.frame_views[key], np.frombuffer(buf.data, dtype=np.uint8, count=self.frame_copy_size))
inputs: dict[str, np.ndarray]) -> dict[str, np.ndarray] | None:
for key in bufs.keys():
ptr = np.frombuffer(bufs[key].data, dtype=np.uint8).ctypes.data
yuv_size = self.frame_buf_params[key][3]
# There is a ringbuffer of imgs, just cache tensors pointing to all of them
cache_key = (key, ptr)
if cache_key not in self._blob_cache:
self._blob_cache[cache_key] = Tensor.from_blob(ptr, (yuv_size,), dtype='uint8', device=self.WARP_DEV)
self.full_frames[key] = self._blob_cache[cache_key]
# Model decides when action is completed, so desire input is just a pulse triggered on rising edge
inputs['desire_pulse'][0] = 0
@@ -216,12 +187,16 @@ class ModelState(ModelStateBase):
self.npy['tfm'][:,:] = transforms['img'][:,:]
self.npy['big_tfm'][:,:] = transforms['big_img'][:,:]
outs, = self.run_model(**{k: self.input_queues[k] for k in MODELD_INPUTS})
if after_enqueue is not None:
after_enqueue()
warped = self.warp(**{k: self.input_queues[k] for k in WARP_INPUTS}, frame=self.full_frames['img'], big_frame=self.full_frames['big_img'])
outs, = self.run_policy(
**{k: self.input_queues[k] for k in POLICY_INPUTS if k in self.input_queues}, warped=warped
)
model_output = outs.numpy()[0]
if self.chestnut and not np.all(np.isfinite(model_output)):
raise RuntimeError("model output not finite")
if self.usbgpu and not np.all(np.isfinite(model_output)):
# TODO remove with prev_feat
cloudlog.error("model output not finite, dropping frame")
return None
outputs_dict = self.parser.parse_outputs(self.slice_outputs(model_output, self.output_slices))
self.npy['prev_feat'][:] = model_output[self.output_slices['hidden_state']]
@@ -230,37 +205,25 @@ class ModelState(ModelStateBase):
return outputs_dict
def warmup(self) -> None:
dummy_frames = {k: np.zeros(self.frame_copy_size, dtype=np.uint8) for k in self.vision_input_names}
dummy_frames = {k: np.zeros(self.frame_buf_params[k][3], dtype=np.uint8) for k in self.vision_input_names}
eye = np.eye(3, dtype=np.float32)
dims = {'desire_pulse': ModelConstants.DESIRE_LEN, 'traffic_convention': 2, 'action_t': 2}
self.run(dummy_frames, dict.fromkeys(self.vision_input_names, eye), {k: np.zeros(v, dtype=np.float32) for k, v in dims.items()})
self.input_queues, self.npy, self.frame_views = make_input_queues(
self.input_shapes, self.frame_skip, device=self.model_device, frame_copy_size=self.frame_copy_size)
self.input_queues, self.npy = make_input_queues(self.input_shapes, self.frame_skip, device=self.QUEUE_DEV)
self.prev_desire[:] = 0
self.full_frames.clear()
self._blob_cache.clear()
def main(demo=False):
cloudlog.warning("modeld init")
chestnut_available = chestnut_present() and chestnut_compiled()
CHESTNUT = False
if chestnut_available:
poller = messaging.Poller()
sock = messaging.sub_sock("chestnutState", poller=poller, conflate=True)
deadline = time.monotonic() + 4. / SERVICE_LIST['deviceState'].frequency
while not CHESTNUT and (remaining := deadline - time.monotonic()) > 0.:
if not poller.poll(round(remaining * 1000)):
break
msg = messaging.recv_one_or_none(sock)
CHESTNUT = msg is not None and msg.valid and chestnut_ready(msg.chestnutState)
if CHESTNUT:
USBGPU = usbgpu_present() and usbgpu_compiled()
if USBGPU:
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
params = Params()
params.put_bool("ChestnutLoading", CHESTNUT)
if chestnut_available and not CHESTNUT:
params.put_bool("ChestnutActive", False)
else:
params.remove("ChestnutActive")
params.put_bool("UsbGpuLoading", USBGPU)
params.remove("UsbGpuActive")
config_realtime_process(7, 54)
@@ -290,7 +253,7 @@ def main(demo=False):
st = time.monotonic()
cloudlog.warning("loading model")
model = None
if CHESTNUT:
if USBGPU:
big_model = None
def load_big():
nonlocal big_model
@@ -304,27 +267,23 @@ def main(demo=False):
loader.start()
loader.join(BIG_MODEL_TIMEOUT)
model = big_model
if model is None:
params.put_bool("ChestnutModelError", True)
params.put_bool("ChestnutActive", model is not None)
if model is not None:
params.remove("ChestnutModelError")
params.put_bool("UsbGpuActive", model is not None)
small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None
small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or USBGPU else None
if model is None:
model = small_model
params.put_bool("ChestnutLoading", False)
params.put_bool("UsbGpuLoading", False)
assert model is not None
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
# messaging
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if CHESTNUT else [])
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutState"] if USBGPU else [])
pm = PubMaster(pub_socks)
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"])
publish_state = PublishState()
params = Params()
chestnut_state = ChestnutState(pm, model.chestnut) if CHESTNUT else None
chestnut_state = ChestnutState(pm, model.usbgpu) if USBGPU else None
# setup filter to track dropped frames
frame_dropped_filter = FirstOrderFilter(0., 10., 1. / ModelConstants.MODEL_RUN_FREQ)
@@ -434,16 +393,13 @@ def main(demo=False):
mt1 = time.perf_counter()
try:
send_chestnut = (chestnut_state is not None and
run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0)
model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None)
model_output = model.run(bufs, transforms, inputs)
except Exception:
if not params.get_bool("ChestnutActive"):
if not params.get_bool("UsbGpuActive"):
raise
# fallback to small model
cloudlog.exception("big model failed, fall back to small")
params.put_bool("ChestnutModelError", True)
params.put_bool("ChestnutActive", False)
params.put_bool("UsbGpuActive", False)
assert small_model is not None
model = small_model
if chestnut_state is not None:
@@ -463,17 +419,18 @@ def main(demo=False):
fill_model_msg(modelv2_send, model_output, action,
publish_state, meta_main.frame_id, meta_extra.frame_id, frame_id,
frame_drop_ratio, meta_main.timestamp_eof, model_execution_time, extrinsics_calibration_seen)
modelv2_send.modelV2.big = model.chestnut
modelv2_send.modelV2.big = model.usbgpu
desire_state = modelv2_send.modelV2.meta.desireState
l_lane_change_prob = desire_state[log.Desire.laneChangeLeft]
r_lane_change_prob = desire_state[log.Desire.laneChangeRight]
lane_change_prob = l_lane_change_prob + r_lane_change_prob
mdv2sp_send = messaging.new_message('modelDataV2SP')
left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego)
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob, left_edge, right_edge)
DH.update(sm['carState'], sm['carControl'].latActive, lane_change_prob)
modelv2_send.modelV2.meta.laneChangeState = DH.lane_change_state
modelv2_send.modelV2.meta.laneChangeDirection = DH.lane_change_direction
mdv2sp_send = messaging.new_message('modelDataV2SP')
left_edge, right_edge = RELC.update_and_fill(modelv2_send.modelV2, mdv2sp_send.modelDataV2SP, v_ego)
mdv2sp_send.modelDataV2SP.laneTurnDirection = DH.lane_turn_direction
fill_driving_model_data(drivingdata_send, modelv2_send)
@@ -484,6 +441,10 @@ def main(demo=False):
pm.send('modelDataV2SP', mdv2sp_send)
last_vipc_frame_id = meta_main.frame_id
if chestnut_state is not None and run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutState'].frequency) == 0:
chestnut_state.send()
if __name__ == "__main__":
try:
import argparse
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1791d5940b2c048d0639813426dd2cf1d6f2a6727ed51e17c8bcea8bbe754123
size 765950064
oid sha256:a501760a9d1d5fef0eab2b8c5d122d06124fc26dc8e0782e0aa94b82a208f0ff
size 1757355221
+10 -10
View File
@@ -123,22 +123,22 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda
ps.setUptime(health.uptime_pkt);
ps.setSafetyTxBlocked(health.safety_tx_blocked_pkt);
ps.setSafetyRxInvalid(health.safety_rx_invalid_pkt);
ps.setIgnitionLine((health.flags_pkt & HEALTH_FLAG_IGNITION_LINE) != 0U);
ps.setIgnitionCan((health.flags_pkt & HEALTH_FLAG_IGNITION_CAN) != 0U);
ps.setControlsAllowed((health.flags_pkt & HEALTH_FLAG_CONTROLS_ALLOWED) != 0U);
ps.setIgnitionLine(health.ignition_line_pkt);
ps.setIgnitionCan(health.ignition_can_pkt);
ps.setControlsAllowed(health.controls_allowed_pkt);
ps.setTxBufferOverflow(health.tx_buffer_overflow_pkt);
ps.setRxBufferOverflow(health.rx_buffer_overflow_pkt);
ps.setPandaType(hw_type);
ps.setSafetyModel(cereal::CarParams::SafetyModel(health.safety_mode_pkt));
ps.setSafetyParam(health.safety_param_pkt);
ps.setFaultStatus(cereal::PandaState::FaultStatus(health.fault_status_pkt));
ps.setPowerSaveEnabled((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U);
ps.setHeartbeatLost((health.flags_pkt & HEALTH_FLAG_HEARTBEAT_LOST) != 0U);
ps.setPowerSaveEnabled((bool)(health.power_save_enabled_pkt));
ps.setHeartbeatLost((bool)(health.heartbeat_lost_pkt));
ps.setAlternativeExperience(health.alternative_experience_pkt);
ps.setHarnessStatus(cereal::PandaState::HarnessStatus(health.car_harness_status_pkt));
ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f);
ps.setInterruptLoad(health.interrupt_load_pkt);
ps.setFanPower(health.fan_power);
ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U);
ps.setSafetyRxChecksInvalid((bool)(health.safety_rx_checks_invalid_pkt));
ps.setSpiErrorCount(health.spi_error_count_pkt);
ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f);
ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f);
@@ -198,10 +198,10 @@ std::optional<bool> send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa
}
if (spoofing_started) {
health.flags_pkt |= HEALTH_FLAG_IGNITION_LINE;
health.ignition_line_pkt = 1;
}
bool ignition_local = ((health.flags_pkt & (HEALTH_FLAG_IGNITION_LINE | HEALTH_FLAG_IGNITION_CAN)) != 0U) && !always_offroad;
bool ignition_local = ((health.ignition_line_pkt != 0) || (health.ignition_can_pkt != 0)) && !always_offroad;
// Make sure CAN buses are live: safety_setter_thread does not work if Panda CAN are silent and there is only one other CAN node
if (health.safety_mode_pkt == (uint8_t)(cereal::CarParams::SafetyModel::SILENT)) {
@@ -209,7 +209,7 @@ std::optional<bool> send_panda_states(PubMaster *pm, Panda *panda, bool is_onroa
}
bool power_save_desired = !ignition_local;
if (((health.flags_pkt & HEALTH_FLAG_POWER_SAVE_ENABLED) != 0U) != power_save_desired) {
if (health.power_save_enabled_pkt != power_save_desired) {
panda->set_power_saving(power_save_desired);
}
@@ -19,30 +19,6 @@
},
"Offroad_ChestnutBranch": {
"text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.",
"severity": -1
},
"Offroad_ChestnutNotDetected": {
"text": "Chestnut not detected. Check USB and 12V connections.",
"severity": 0
},
"Offroad_ChestnutOverheated": {
"text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.",
"severity": 0
},
"Offroad_ChestnutPcieUnavailable": {
"text": "%1",
"severity": 0
},
"Offroad_ChestnutUncompiled": {
"text": "Chestnut model not compiled. Keep ignition on and reboot the comma.",
"severity": 0
},
"Offroad_ChestnutUpdateFailed": {
"text": "Chestnut update failed. Check the USB cable.",
"severity": 0
},
"Offroad_ChestnutUsbSlow": {
"text": "Chestnut USB link is slow. Check the USB cable. The current speed is %1.",
"severity": 0
},
"Offroad_UnregisteredHardware": {
+5 -71
View File
@@ -32,14 +32,7 @@ from openpilot.sunnypilot.selfdrive.car.car_specific import CarSpecificEventsSP
from openpilot.sunnypilot.selfdrive.car.cruise_helpers import CruiseHelper
from openpilot.sunnypilot.selfdrive.car.intelligent_cruise_button_management.controller import IntelligentCruiseButtonManagement
from openpilot.sunnypilot.selfdrive.selfdrived.button_state_tracker import ButtonStateTracker
from openpilot.sunnypilot.selfdrive.selfdrived.assisted_driving_milestones import (
AssistCategory,
AssistedDrivingMilestones,
MilestoneEvent,
MilestoneStore,
)
from openpilot.sunnypilot.selfdrive.selfdrived.events import EventsSP
from openpilot.sunnypilot.system.statsd import statlog
REPLAY = "REPLAY" in os.environ
SIMULATION = "SIMULATION" in os.environ
@@ -95,8 +88,7 @@ class SelfdriveD(CruiseHelper):
self.big_model_ready_t = 0.
# Setup sockets
self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] +
['selfdriveStateSP', 'onroadEventsSP', 'assistedDrivingMilestoneState'])
self.pm = messaging.PubMaster(['selfdriveState', 'onroadEvents'] + ['selfdriveStateSP', 'onroadEventsSP'])
self.gps_location_service = get_gps_location_service(self.params)
self.gps_packets = [self.gps_location_service]
@@ -135,7 +127,6 @@ class SelfdriveD(CruiseHelper):
self.params.remove("ExperimentalMode")
self.CS_prev = car.CarState.new_message()
self.car_state_log_mono_time = 0
self.AM = AlertManager()
self.events = Events()
@@ -146,11 +137,6 @@ class SelfdriveD(CruiseHelper):
self.cruise_mismatch_counter = 0
self.last_steering_pressed_frame = 0
self.distance_traveled = 0
self.assisted_driving_milestones = AssistedDrivingMilestones(MilestoneStore(self.params))
self.assisted_driving_milestones_enabled = bool(self.params.get("AssistedDrivingMilestonesEnabled", return_default=True))
self.assisted_driving_milestone_drive_id = ""
self._milestone_event: MilestoneEvent | None = None
self._milestone_event_expires_ns = 0
self.last_functional_fan_frame = 0
self.events_prev = []
self.logged_comm_issue = None
@@ -209,18 +195,17 @@ class SelfdriveD(CruiseHelper):
self.events.add(EventName.joystickDebug)
self.startup_event = None
loading = self.params.get_bool("ChestnutLoading")
loading = self.params.get_bool("UsbGpuLoading")
if self.big_model_loading and not loading:
self.big_model_ready_t = time.monotonic()
self.events_sp.add(custom.OnroadEventSP.EventName.bigModelReady)
self.big_model_loading = loading
if self.big_model_loading:
self.events.add(EventName.bigModelLoading)
big_active = self.params.get("ChestnutActive")
chestnut_present = self.sm['deviceState'].chestnutPresent
big_active = self.params.get("UsbGpuActive")
usbgpu_present = self.sm['deviceState'].chestnutPresent
model_unavailable = big_active is True and self.sm.seen['modelV2'] and not self.sm.alive['modelV2']
big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present)
big_failed = big_active is False or model_unavailable or (self.big_model_active and not usbgpu_present)
if big_failed and not self.big_model_failed:
self.events.add(EventName.bigModelFailed)
self.big_model_failed = big_failed
@@ -542,8 +527,6 @@ class SelfdriveD(CruiseHelper):
def data_sample(self):
_car_state = messaging.recv_one(self.car_state_sock)
CS = _car_state.carState if _car_state else self.CS_prev
if _car_state is not None:
self.car_state_log_mono_time = _car_state.logMonoTime
self.sm.update(0)
@@ -662,31 +645,6 @@ class SelfdriveD(CruiseHelper):
self.pm.send('onroadEventsSP', ce_send_sp)
self.events_sp_prev = self.events_sp.names.copy()
def publish_assisted_driving_milestones(self, now_ns: int, event: MilestoneEvent | None) -> None:
if event is not None:
self._milestone_event = event
self._milestone_event_expires_ns = now_ns + 1_000_000_000
elif now_ns >= self._milestone_event_expires_ns:
self._milestone_event = None
if event is None and self.sm.frame % 10 != 0:
return
snapshot = self.assisted_driving_milestones.snapshot()
msg = messaging.new_message("assistedDrivingMilestoneState")
msg.valid = True
state = msg.assistedDrivingMilestoneState
state.enabled = self.assisted_driving_milestones_enabled
state.madsDistanceMeters = snapshot.distances_meters[AssistCategory.MADS]
state.fullAssistDistanceMeters = snapshot.distances_meters[AssistCategory.FULL_ASSIST]
if self._milestone_event is not None:
state.event.id = self._milestone_event.event_id
state.event.category = self._milestone_event.category.value
state.event.distanceMeters = self._milestone_event.distance_meters
state.event.previousDistanceMeters = self._milestone_event.previous_distance_meters
state.event.unit = self._milestone_event.unit.value
self.pm.send("assistedDrivingMilestoneState", msg)
def step(self):
CS = self.data_sample()
self.update_events(CS)
@@ -696,28 +654,6 @@ class SelfdriveD(CruiseHelper):
self.mads.update(CS)
self.update_alerts(CS)
now_ns = time.monotonic_ns()
if not self.assisted_driving_milestone_drive_id:
self.assisted_driving_milestone_drive_id = self.params.get("CurrentRoute") or ""
self.assisted_driving_milestones.set_drive_id(self.assisted_driving_milestone_drive_id)
car_control = self.sm['carControl']
milestone_event = self.assisted_driving_milestones.update(
self.car_state_log_mono_time,
CS.vEgo,
lat_active=car_control.latActive,
long_active=car_control.longActive,
is_metric=self.is_metric,
enabled=self.assisted_driving_milestones_enabled,
)
if milestone_event is not None:
cloudlog.event("assisted_driving_milestone_reached",
event_id=milestone_event.event_id,
category=milestone_event.category.value,
distance_meters=milestone_event.distance_meters)
statlog.gauge(f"assisted_driving_milestone.{milestone_event.category.value}.meters",
milestone_event.distance_meters)
self.publish_assisted_driving_milestones(now_ns, milestone_event)
self.button_state_tracker.update(CS)
self.publish_selfdriveState(CS)
@@ -730,7 +666,6 @@ class SelfdriveD(CruiseHelper):
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
self.personality = self.params.get("LongitudinalPersonality", return_default=True)
self.assisted_driving_milestones_enabled = bool(self.params.get("AssistedDrivingMilestonesEnabled", return_default=True))
self.mads.read_params()
time.sleep(0.1)
@@ -744,7 +679,6 @@ class SelfdriveD(CruiseHelper):
self.step()
self.rk.monitor_time()
finally:
self.assisted_driving_milestones.close()
e.set()
t.join()
@@ -152,7 +152,7 @@ def migrate_drivingModelData(msgs):
add_ops = []
for _, msg in msgs:
dmd = messaging.new_message('drivingModelData', valid=msg.valid, logMonoTime=msg.logMonoTime)
for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "big", "action"]:
for field in ["frameId", "frameIdExtra", "frameDropPerc", "modelExecutionTime", "action"]:
setattr(dmd.drivingModelData, field, getattr(msg.modelV2, field))
for meta_field in ["laneChangeState", "laneChangeState"]:
setattr(dmd.drivingModelData.meta, meta_field, getattr(msg.modelV2.meta, meta_field))
@@ -33,9 +33,9 @@ MODEL_REPLAY_BUCKET="model_replay_master"
GITHUB = GithubUtils(API_TOKEN, DATA_TOKEN)
EXEC_TIMINGS = [
# model, instant max, average max, chestnut average max
("modelV2", 0.05, 0.03, 0.05),
("driverStateV2", 0.05, 0.018, 0.018),
# model, instant max, average max
("modelV2", 0.05, 0.028),
("driverStateV2", 0.05, 0.018),
]
def get_log_fn(test_route, ref="master"):
@@ -169,13 +169,11 @@ def model_replay(lr, frs):
dmonitoringmodeld_msgs = replay_process(dmonitoringmodeld, dmodeld_logs, frs)
msgs = modeld_msgs + dmonitoringmodeld_msgs
chestnut = any(m.modelV2.big for m in modeld_msgs if m.which() == "modelV2")
header = ['model', 'max instant', 'max instant allowed', 'average', 'max average allowed', 'test result']
rows = []
timings_ok = True
for (s, instant_max, avg_max, chestnut_avg_max) in EXEC_TIMINGS:
avg_max = chestnut_avg_max if chestnut else avg_max
for (s, instant_max, avg_max) in EXEC_TIMINGS:
ts = [getattr(m, s).modelExecutionTime for m in msgs if m.which() == s]
# TODO some init can happen in first iteration
ts = ts[1:]
@@ -1,7 +1,7 @@
import time
import pyray as rl
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.selfdrive.ui.ui_state import ui_state
@@ -26,8 +26,8 @@ class BodyLayout(Widget):
self._last_input_time = time.monotonic()
self._was_active = False
self._offroad_label = UnifiedLabel("turn on ignition to use", 95 if gui_app.big_ui() else 45, FontWeight.DISPLAY,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
def draw_dot_grid(self, rect: rl.Rectangle, dots: list[tuple[int, int]], color: rl.Color):
spacing = min(rect.height / GRID_ROWS, rect.width / GRID_COLS)
+2 -2
View File
@@ -8,7 +8,7 @@ from openpilot.selfdrive.ui.widgets.exp_mode_button import ExperimentalModeButto
from openpilot.selfdrive.ui.widgets.prime import PrimeWidget
from openpilot.selfdrive.ui.widgets.setup import SetupWidget
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr, trn
from openpilot.system.ui.widgets.label import gui_label
from openpilot.system.ui.widgets import Widget
@@ -178,7 +178,7 @@ class HomeLayout(Widget):
version_rect = rl.Rectangle(self.header_rect.x + self.header_rect.width - version_text_width, self.header_rect.y,
version_text_width, self.header_rect.height)
gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=TextAlignment.RIGHT)
gui_label(version_rect, self._version_text, 48, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
def _render_home_content(self):
self._render_left_column()
+4 -4
View File
@@ -5,7 +5,7 @@ from enum import IntEnum
import pyray as rl
from openpilot.common.basedir import BASEDIR
from openpilot.system.ui.lib.application import FontWeight, TextAlignment, gui_app
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
@@ -115,9 +115,9 @@ class TermsPage(Widget):
self._on_accept = on_accept
self._on_decline = on_decline
self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.LEFT)
self._title = Label(tr("Welcome to sunnypilot"), font_size=90, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._desc = Label(tr("You must accept the Terms of Service to use sunnypilot. Read the latest terms at https://sunnypilot.ai/terms before continuing."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._decline_btn = Button(tr("Decline"), click_callback=on_decline)
self._accept_btn = Button(tr("Agree"), button_style=ButtonStyle.PRIMARY, click_callback=on_accept)
@@ -150,7 +150,7 @@ class DeclinePage(Widget):
def __init__(self, back_callback=None):
super().__init__()
self._text = Label(tr("You must accept the Terms of Service in order to use sunnypilot."),
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT)
font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT)
self._back_btn = Button(tr("Back"), click_callback=back_callback)
self._uninstall_btn = Button(tr("Decline, uninstall sunnypilot"), button_style=ButtonStyle.DANGER,
click_callback=self._on_uninstall_clicked)
@@ -199,9 +199,6 @@ class SoftwareLayout(Widget):
selection = self._branch_dialog.selection
ui_state.params.put("UpdaterTargetBranch", selection, block=True)
self._branch_btn.action_item.set_value(selection)
self._download_btn.action_item.set_enabled(False)
self._waiting_for_updater = True
self._waiting_start_ts = time.monotonic()
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
self._branch_dialog = None
+1 -8
View File
@@ -168,16 +168,9 @@ class Sidebar(Widget, SidebarSP):
# Home/Flag button
flag_pressed = mouse_down and rl.check_collision_point_rec(mouse_pos, HOME_BTN)
button_img = self._flag_img if ui_state.started else self._home_img
button_pos = rl.Vector2(HOME_BTN.x, HOME_BTN.y)
icon_opacity = 1.0
if gui_app.sunnypilot_ui():
button_img, button_pos, icon_opacity = SidebarSP._get_home_icon(self, button_img)
tint = Colors.BUTTON_PRESSED if (ui_state.started and flag_pressed) else Colors.BUTTON_NORMAL
if icon_opacity < 1.0:
tint = rl.Color(tint[0], tint[1], tint[2], int(255 * icon_opacity))
rl.draw_texture_ex(button_img, button_pos, 0.0, 1.0, tint)
rl.draw_texture_ex(button_img, rl.Vector2(HOME_BTN.x, HOME_BTN.y), 0.0, 1.0, tint)
# Microphone button
if self._recording_audio:
+11 -27
View File
@@ -1,5 +1,4 @@
import datetime
import math
import time
from openpilot.cereal import log
@@ -9,8 +8,8 @@ from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.layouts import HBoxLayout
from openpilot.system.ui.widgets.icon_widget import IconWidget
from openpilot.system.ui.widgets.label import UnifiedLabel, gui_label
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignment, TextAlignmentVertical
from openpilot.selfdrive.ui.ui_state import ui_state, ChestnutState
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.version import RELEASE_BRANCHES
HEAD_BUTTON_FONT_SIZE = 40
@@ -70,8 +69,8 @@ class AlertsPill(Widget):
count_rect = rl.Rectangle(self.rect.x + self.COUNT_OFFSET, self.rect.y, pill_w - self.COUNT_OFFSET, pill_h)
gui_label(count_rect, str(alert_count), font_size=36,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
class NetworkIcon(Widget):
@@ -140,10 +139,8 @@ class MiciHomeLayout(Widget):
self._version_text = self._get_version_text()
self._experimental_icon = IconWidget("icons_mici/experimental_mode.png", (48, 48))
self._usb_icon = IconWidget("icons_mici/usb.png", (62, 40))
self._chestnut_icon = IconWidget("icons_mici/chestnut_green.png", (68, 40))
self._chestnut_loading_icon = IconWidget("icons_mici/chestnut.png", (68, 40))
self._chestnut_failed_icon = IconWidget("icons_mici/chestnut_orange.png", (68, 40))
self._egpu_icon = IconWidget("icons_mici/egpu_green.png", (50, 37))
self._egpu_icon_gray = IconWidget("icons_mici/egpu_gray.png", (50, 37))
self._mic_icon = IconWidget("icons_mici/microphone.png", (32, 46))
self._body_icon = IconWidget("icons_mici/body.png", (54, 37))
@@ -153,15 +150,13 @@ class MiciHomeLayout(Widget):
IconWidget("icons_mici/settings.png", (48, 48), opacity=0.9),
NetworkIcon(),
self._experimental_icon,
self._usb_icon,
self._chestnut_icon,
self._chestnut_loading_icon,
self._chestnut_failed_icon,
self._egpu_icon,
self._egpu_icon_gray,
self._body_icon,
self._mic_icon,
], spacing=18)
self._openpilot_label = UnifiedLabel("openpilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False)
self._openpilot_label = UnifiedLabel("sunnypilot", font_size=96, font_weight=FontWeight.DISPLAY, max_width=480, wrap_text=False)
self._version_label = UnifiedLabel("", font_size=36, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
self._large_version_label = UnifiedLabel("", font_size=64, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
self._date_label = UnifiedLabel("", font_size=36, text_color=rl.GRAY, font_weight=FontWeight.ROMAN, max_width=480, wrap_text=False)
@@ -252,20 +247,9 @@ class MiciHomeLayout(Widget):
self._version_commit_label.render()
# ***** Center-aligned bottom section icons *****
usb_connected = ui_state.usb_connected
usb_unknown = ui_state.usb_unknown
chestnut_state = ui_state.chestnut_state
self._experimental_icon.set_visible(ui_state.experimental_mode)
if gui_app.sunnypilot_ui():
self._set_chestnut_visibility()
else:
self._usb_icon.set_visible(usb_connected and usb_unknown)
self._chestnut_icon.set_visible(not usb_unknown and chestnut_state not in
(ChestnutState.LOADING, ChestnutState.UNCOMPILED, ChestnutState.FAILED) and
(usb_connected or chestnut_state in (ChestnutState.READY, ChestnutState.ACTIVE)))
self._chestnut_loading_icon.set_visible(not usb_unknown and chestnut_state == ChestnutState.LOADING)
self._chestnut_loading_icon.set_opacity(0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0)))
self._chestnut_failed_icon.set_visible(not usb_unknown and chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED))
self._egpu_icon.set_visible(ui_state.sm["deviceState"].chestnutPresent and ui_state.usbgpu_compiled)
self._egpu_icon_gray.set_visible(ui_state.sm["deviceState"].chestnutPresent and not ui_state.usbgpu_compiled)
self._mic_icon.set_visible(ui_state.recording_audio)
self._body_icon.set_visible(bool(ui_state.is_body))
+1 -7
View File
@@ -1,8 +1,5 @@
import os
import pyray as rl
import openpilot.cereal.messaging as messaging
from openpilot.common.hardware import PC
from openpilot.selfdrive.ui.mici.layouts.home import MiciHomeLayout
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsLayout
from openpilot.selfdrive.ui.mici.layouts.offroad_alerts import MiciOffroadAlerts
@@ -64,8 +61,7 @@ class MiciMainLayout(Scroller):
# Start onboarding if terms or training not completed, make sure to push after self
self._onboarding_window = OnboardingWindow(lambda: gui_app.pop_widgets_to(self))
skip_onboarding_for_milestone_preview = PC and os.getenv("SP_MILESTONE_PREVIEW") == "1"
if not self._onboarding_window.completed and not skip_onboarding_for_milestone_preview:
if not self._onboarding_window.completed:
gui_app.push_widget(self._onboarding_window)
# initialize correct onroad layout
@@ -123,8 +119,6 @@ class MiciMainLayout(Scroller):
self._onroad_time_delay = rl.get_time()
else:
self._scroll_to(self._home_layout)
if hasattr(self._home_layout, "request_drive_summary"):
self._home_layout.request_drive_summary()
# FIXME: these two pops can interrupt user interacting in the settings
if self._onroad_time_delay is not None and rl.get_time() - self._onroad_time_delay >= ONROAD_DELAY:
@@ -11,7 +11,7 @@ from openpilot.common.hardware import HARDWARE
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import Scroller
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
REFRESH_INTERVAL = 5.0 # seconds
@@ -62,12 +62,12 @@ class AlertItem(Widget):
self._icon_green = gui_app.texture("icons_mici/offroad_alerts/green_wheel.png", self.ICON_SIZE, self.ICON_SIZE)
self._title_label = UnifiedLabel(text="", font_size=32, font_weight=FontWeight.SEMI_BOLD, text_color=self.TEXT_COLOR,
alignment=TextAlignment.LEFT,
alignment_vertical=TextAlignmentVertical.TOP, line_height=0.95)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP, line_height=0.95)
self._body_label = UnifiedLabel(text="", font_size=28, font_weight=FontWeight.ROMAN, text_color=self.TEXT_COLOR,
alignment=TextAlignment.LEFT,
alignment_vertical=TextAlignmentVertical.BOTTOM, line_height=0.95)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, line_height=0.95)
self._title_text = ""
self._body_text = ""
@@ -200,8 +200,8 @@ class MiciOffroadAlerts(Scroller):
# Create empty state label
self._empty_label = UnifiedLabel(tr("no alerts"), 65, FontWeight.DISPLAY, rl.WHITE,
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
# Build initial alert list
self._build_alerts()
@@ -4,7 +4,7 @@ import pyray as rl
from collections.abc import Callable
from openpilot.common.filter_simple import FirstOrderFilter
from openpilot.common.qrcode import make_texture
from openpilot.system.ui.lib.application import FontWeight, gui_app, TextAlignment
from openpilot.system.ui.lib.application import FontWeight, gui_app
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import SmallCircleIconButton
from openpilot.system.ui.widgets.scroller import NavScroller, Scroller
@@ -35,7 +35,7 @@ class DriverCameraSetupDialog(BaseCabinCameraDialog):
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=64, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
return
@@ -74,10 +74,6 @@ class SoftwareInfoLayoutMici(Widget):
class CheckUpdateButton(BigButton):
UPDATER_PROC = "openpilot.system.updated.updated"
CHECK_FOR_UPDATE = "SIGUSR1"
DOWNLOAD_UPDATE = "SIGHUP"
def __init__(self):
self._txt_update_icon = gui_app.texture("icons_mici/settings/device/update.png", 64, 75)
self._txt_up_to_date_icon = gui_app.texture("icons_mici/settings/device/up_to_date.png", 64, 64)
@@ -101,20 +97,15 @@ class CheckUpdateButton(BigButton):
gui_app.push_widget(dlg)
return
self._signal_updater(self.DOWNLOAD_UPDATE if self.get_value() == "download update" else self.CHECK_FOR_UPDATE)
def check_for_update(self):
self._signal_updater(self.CHECK_FOR_UPDATE)
def _signal_updater(self, sig: str):
self.set_enabled(False)
self._state = UpdaterState.WAITING_FOR_UPDATER
self._hide_value_t = None
self.set_value("")
self.set_icon(self._txt_update_icon)
def run():
subprocess.run(f"pkill -{sig} -f {self.UPDATER_PROC}", shell=True)
if self.get_value() == "download update":
subprocess.run("pkill -SIGHUP -f openpilot.system.updated.updated", shell=True)
else:
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
threading.Thread(target=run, daemon=True).start()
@@ -193,7 +184,7 @@ class CheckUpdateButton(BigButton):
class InstallUpdateButton(BigButton):
def __init__(self):
super().__init__("install now", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
super().__init__("install update", "", gui_app.texture("icons_mici/settings/device/reboot.png", 64, 70))
self.set_visible(lambda: ui_state.is_offroad() and ui_state.params.get_bool("UpdateAvailable"))
def _update_state(self):
@@ -241,9 +232,8 @@ class BranchSelectPage(NavScroller):
class TargetBranchButton(BigButton):
def __init__(self, check_update_btn: CheckUpdateButton):
def __init__(self):
super().__init__("target branch", ui_state.params.get("UpdaterTargetBranch") or "")
self._check_update_btn = check_update_btn
self.set_click_callback(self._on_click)
self.set_visible(not ui_state.params.get_bool("IsTestedBranch"))
self.set_enabled(lambda: ui_state.is_offroad())
@@ -256,15 +246,12 @@ class TargetBranchButton(BigButton):
self.set_value(target)
def _on_click(self):
if not ui_state.params.get("UpdaterAvailableBranches"):
gui_app.push_widget(BigDialog("", tr("Failed to get available branches. Ensure you're connected to the internet and try again.")))
return
gui_app.push_widget(BranchSelectPage(self._on_select))
def _on_select(self, branch: str):
ui_state.params.put("UpdaterTargetBranch", branch, block=True)
self.set_value(branch)
self._check_update_btn.check_for_update()
subprocess.run("pkill -SIGUSR1 -f openpilot.system.updated.updated", shell=True)
class SoftwareLayoutMici(NavScroller):
@@ -278,11 +265,10 @@ class SoftwareLayoutMici(NavScroller):
gui_app.texture("icons_mici/settings/device/uninstall.png", 64, 64),
uninstall_openpilot_callback, exit_on_confirm=False)
check_update_btn = CheckUpdateButton()
self._scroller.add_widgets([
SoftwareInfoLayoutMici(),
check_update_btn,
CheckUpdateButton(),
InstallUpdateButton(),
TargetBranchButton(check_update_btn),
TargetBranchButton(),
uninstall_openpilot_btn,
])
@@ -47,7 +47,6 @@ class TogglesLayoutMici(NavScroller):
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled")
always_on_dm_toggle = BigParamControl("always-on driver monitor", "AlwaysOnDM")
milestone_celebrations_toggle = BigParamControl("assisted driving milestones", "AssistedDrivingMilestonesEnabled")
record_front = BigParamControl("record & upload cabin camera", "RecordFront", toggle_callback=restart_needed_callback)
record_mic = BigParamControl("record & upload mic audio", "RecordAudio", toggle_callback=restart_needed_callback)
enable_openpilot = BigParamControl("enable sunnypilot", "OpenpilotEnabledToggle", toggle_callback=restart_needed_callback)
@@ -58,7 +57,6 @@ class TogglesLayoutMici(NavScroller):
is_metric_toggle,
ldw_toggle,
always_on_dm_toggle,
milestone_celebrations_toggle,
record_front,
record_mic,
enable_openpilot,
@@ -70,7 +68,6 @@ class TogglesLayoutMici(NavScroller):
("IsMetric", is_metric_toggle),
("IsLdwEnabled", ldw_toggle),
("AlwaysOnDM", always_on_dm_toggle),
("AssistedDrivingMilestonesEnabled", milestone_celebrations_toggle),
("RecordFront", record_front),
("RecordAudio", record_mic),
("OpenpilotEnabledToggle", enable_openpilot),
@@ -10,7 +10,7 @@ from opendbc.car.structs import car
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.filter_simple import BounceFilter, FirstOrderFilter
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
@@ -20,7 +20,6 @@ AlertSize = log.SelfdriveState.AlertSize
AlertStatus = log.SelfdriveState.AlertStatus
ALERT_MARGIN = 18
ALERT_BACKGROUND_OPACITY = 0.90
ALERT_FONT_SMALL = 66 - 50
ALERT_FONT_BIG = 88 - 40
@@ -280,7 +279,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
def _draw_background(self, alert: Alert) -> None:
# draw top gradient for alert text at top
color = ALERT_COLORS.get(alert.status, ALERT_COLORS[AlertStatus.normal])
color = rl.Color(color.r, color.g, color.b, int(255 * ALERT_BACKGROUND_OPACITY * self._alpha_filter.x))
color = rl.Color(color.r, color.g, color.b, int(255 * 0.90 * self._alpha_filter.x))
translucent_color = rl.Color(color.r, color.g, color.b, int(0 * self._alpha_filter.x))
small_alert_height = round(self._rect.height * 0.583) # 140px at mici height
@@ -334,7 +333,7 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
self._alert_text1_label.set_text(alert_text1)
self._alert_text1_label.set_text_color(color)
self._alert_text1_label.set_font_size(font_size)
self._alert_text1_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT)
self._alert_text1_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text1_label.render(text_rect1)
alert_text2 = alert.text2.lower()
@@ -366,5 +365,5 @@ class AlertRenderer(Widget, SpeedLimitAlertRenderer):
self._alert_text2_label.set_text(alert_text2)
self._alert_text2_label.set_text_color(color)
self._alert_text2_label.set_font_size(small_font_size)
self._alert_text2_label.set_alignment(TextAlignment.LEFT if icon_side != 'left' else TextAlignment.RIGHT)
self._alert_text2_label.set_alignment(rl.GuiTextAlignment.TEXT_ALIGN_LEFT if icon_side != 'left' else rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
self._alert_text2_label.render(text_rect2)
@@ -11,7 +11,7 @@ from openpilot.selfdrive.ui.mici.onroad.hud_renderer import HudRenderer
from openpilot.selfdrive.ui.mici.onroad.model_renderer import ModelRenderer
from openpilot.selfdrive.ui.mici.onroad.confidence_ball import ConfidenceBall
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import FontWeight, gui_app, MousePos, MouseEvent
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets import Widget
from openpilot.common.filter_simple import BounceFilter
@@ -19,15 +19,10 @@ from openpilot.common.transformations.camera import DEVICE_CAMERAS, DeviceCamera
from openpilot.common.transformations.orientation import rot_from_euler
from enum import IntEnum
MILESTONE_CELEBRATION_ENABLED = gui_app.sunnypilot_ui()
if gui_app.sunnypilot_ui():
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.hud_renderer import HudRendererSP as HudRenderer
from openpilot.selfdrive.ui.sunnypilot.ui_state import OnroadTimerStatus
if MILESTONE_CELEBRATION_ENABLED:
from openpilot.selfdrive.ui.sunnypilot.onroad.milestone_celebration import MilestoneCelebration
OpState = log.SelfdriveState.OpenpilotState
CALIBRATED = log.ExtrinsicsCalibration.Status.calibrated
NARROW_ROAD_CAM = VisionStreamType.VISION_STREAM_NARROW_ROAD
@@ -161,11 +156,10 @@ class AugmentedRoadView(CameraView):
self._alert_renderer = AlertRenderer()
self._driver_state_renderer = DriverStateRenderer()
self._confidence_ball = ConfidenceBall()
self._milestone_celebration = self._child(MilestoneCelebration()) if MILESTONE_CELEBRATION_ENABLED else None
self._offroad_label = UnifiedLabel("start the car to\nuse sunnypilot", 54, FontWeight.DISPLAY,
text_color=rl.Color(255, 255, 255, int(255 * 0.9)),
alignment=TextAlignment.CENTER,
alignment_vertical=TextAlignmentVertical.MIDDLE)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE)
self._fade_texture = gui_app.texture("icons_mici/onroad/onroad_fade.png")
@@ -229,12 +223,6 @@ class AugmentedRoadView(CameraView):
alert_to_render, not_animating_out = self._alert_renderer.will_render()
if self._milestone_celebration is not None:
if alert_to_render is not None:
self._milestone_celebration.cancel_for_alert()
else:
self._milestone_celebration.render(self._content_rect)
# Hide DMoji when disengaged unless AlwaysOnDM is enabled
should_draw_dmoji = (not self._hud_renderer.drawing_top_icons() and
(ui_state.status != UIStatus.DISENGAGED or ui_state.always_on_dm))
@@ -259,6 +247,7 @@ class AugmentedRoadView(CameraView):
self._confidence_ball.render(self.rect)
self._bookmark_icon.render(self.rect)
def _switch_stream_if_needed(self, sm):
if sm['selfdriveState'].experimentalMode and WIDE_CAM in self.available_streams:
v_ego = sm['carState'].vEgo
@@ -366,12 +355,10 @@ class AugmentedRoadView(CameraView):
return self._cached_matrix
def show_event(self):
super().show_event()
if gui_app.sunnypilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.RESUME)
def hide_event(self):
super().hide_event()
if gui_app.sunnypilot_ui():
ui_state.reset_onroad_sleep_timer(OnroadTimerStatus.PAUSE)
@@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType
from openpilot.selfdrive.ui.mici.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.mici.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.nav_widget import NavWidget
@@ -76,7 +76,7 @@ class BaseCabinCameraDialog(Widget):
if not self._camera_view.frame:
gui_label(rect, tr("camera starting"), font_size=54, font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER)
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
rl.end_scissor_mode()
self._publish_alert_sound(None)
return
@@ -124,12 +124,12 @@ class BaseCabinCameraDialog(Widget):
awareness_pct = dm_state.visionPolicyState.awarenessPercent if is_vision else dm_state.wheeltouchPolicyState.awarenessPercent
gui_label(rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height),
f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.RIGHT,
alignment_vertical=TextAlignmentVertical.TOP,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, f"Awareness: {awareness_pct:.0f}%", font_size=44, font_weight=FontWeight.MEDIUM,
alignment=TextAlignment.RIGHT,
alignment_vertical=TextAlignmentVertical.TOP,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
if dm_state.alertLevel == log.DriverMonitoringState.AlertLevel.none:
@@ -137,16 +137,16 @@ class BaseCabinCameraDialog(Widget):
# Show alert level
alert_level_str = f"{'Pay Attention' if is_vision else 'Touch Wheel'} - level {dm_state.alertLevel}"
alignment = TextAlignment.RIGHT if self.driver_state_renderer.is_rhd else TextAlignment.LEFT
alignment = rl.GuiTextAlignment.TEXT_ALIGN_RIGHT if self.driver_state_renderer.is_rhd else rl.GuiTextAlignment.TEXT_ALIGN_LEFT
shadow_rect = rl.Rectangle(rect.x + 2, rect.y + 2, rect.width, rect.height)
gui_label(shadow_rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=TextAlignmentVertical.BOTTOM,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(0, 0, 0, 180))
gui_label(rect, alert_level_str, font_size=40, font_weight=FontWeight.BOLD,
alignment=alignment,
alignment_vertical=TextAlignmentVertical.BOTTOM,
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM,
color=rl.Color(255, 255, 255, int(255 * 0.9)))
def _load_eye_textures(self):
@@ -3,7 +3,7 @@ import pyray as rl
from dataclasses import dataclass
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.mici.onroad.torque_bar import TorqueBar
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus, ChestnutState
from openpilot.selfdrive.ui.ui_state import ui_state, UIStatus
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
@@ -107,7 +107,8 @@ class HudRenderer(Widget):
self.speed: float = 0.0
self.v_ego_cluster_seen: bool = False
self._engaged: bool = False
self._chestnut_fade_time: float = 0
self._small_model_engaged: bool = False
self._egpu_fade_time: float = 0
self._can_draw_top_icons = True
self._show_wheel_critical = False
@@ -123,15 +124,17 @@ class HudRenderer(Widget):
self._txt_wheel: rl.Texture = gui_app.texture('icons_mici/wheel.png', 50, 50)
self._txt_wheel_critical: rl.Texture = gui_app.texture('icons_mici/wheel_critical.png', 50, 50)
self._txt_exclamation_point: rl.Texture = gui_app.texture('icons_mici/exclamation_point.png', 9, 44)
self._txt_chestnut: rl.Texture = gui_app.texture('icons_mici/chestnut.png', 60, 44)
self._txt_chestnut_green: rl.Texture = gui_app.texture('icons_mici/chestnut_green.png', 60, 44)
self._txt_chestnut_orange: rl.Texture = gui_app.texture('icons_mici/chestnut_orange.png', 75, 44)
self._chestnut_icon: rl.Texture | None = None
self._txt_egpu: rl.Texture = gui_app.texture('icons_mici/egpu.png', 60, 44)
self._txt_egpu_green: rl.Texture = gui_app.texture('icons_mici/egpu_green.png', 60, 44)
self._txt_egpu_orange: rl.Texture = gui_app.texture('icons_mici/egpu_orange.png', 60, 44)
self._txt_egpu_crossed: rl.Texture = gui_app.texture('icons_mici/egpu_crossed.png', 60, 52)
self._egpu_icon: rl.Texture | None = None
self._wheel_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
self._wheel_y_filter = FirstOrderFilter(0, 0.1, 1 / gui_app.target_fps)
self._set_speed_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._chestnut_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
self._egpu_alpha_filter = FirstOrderFilter(0.0, 0.1, 1 / gui_app.target_fps)
def set_wheel_critical_icon(self, critical: bool):
"""Set the wheel icon to critical or normal state."""
@@ -162,10 +165,13 @@ class HudRenderer(Widget):
controls_state.deprecated.vCruise if v_cruise_cluster == 0.0 else v_cruise_cluster
)
engaged = sm['selfdriveState'].enabled
if (engaged and not self._engaged and not ui_state.usbgpu_loading and ui_state.usbgpu_active is not True and
ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame):
self._small_model_engaged = True
if engaged != self._engaged:
self._egpu_fade_time = rl.get_time() if engaged else 0
if (set_speed != self.set_speed and engaged) or (engaged and not self._engaged):
self._set_speed_changed_time = rl.get_time()
if engaged != self._engaged:
self._chestnut_fade_time = rl.get_time() if engaged else 0
self._engaged = engaged
self.set_speed = set_speed
self.is_cruise_set = 0 < self.set_speed < SET_SPEED_NA
@@ -185,7 +191,8 @@ class HudRenderer(Widget):
if self.is_cruise_set:
self._draw_set_speed(rect)
self._draw_model_source(rect)
if ui_state.usbgpu and ui_state.usbgpu_compiled:
self._draw_model_source(rect)
self._draw_steering_wheel(rect)
@@ -193,24 +200,30 @@ class HudRenderer(Widget):
if ui_state.sm.recv_frame['selfdriveState'] < ui_state.started_frame:
return
loading = ui_state.chestnut_state == ChestnutState.LOADING
big_failed = (ui_state.usbgpu_active is False or not ui_state.sm['deviceState'].chestnutPresent or
(ui_state.usbgpu_active is True and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame and
not ui_state.sm.alive['modelV2']) or
(ui_state.usbgpu_active is None and ui_state.sm.recv_frame['modelV2'] > ui_state.started_frame))
self._small_model_engaged &= big_failed
loading = ui_state.usbgpu_loading or (ui_state.usbgpu_active is None and not big_failed)
if loading:
icon = self._txt_chestnut
opacity = 0.35 + 0.65 * (0.5 - 0.5 * math.cos(rl.get_time() * 6.0))
elif ui_state.chestnut_state in (ChestnutState.UNCOMPILED, ChestnutState.FAILED):
icon = self._txt_chestnut_orange
opacity = 1.0
elif ui_state.chestnut_state == ChestnutState.ACTIVE:
icon = self._txt_chestnut_green
pulse = 0.5 - 0.5 * math.cos(rl.get_time() * 6.0)
icon = self._txt_egpu
opacity = 0.35 + 0.65 * pulse
elif self._small_model_engaged:
icon = self._txt_egpu_crossed
opacity = 0.65
elif big_failed:
icon = self._txt_egpu_orange
opacity = 1.0
else:
return
icon = self._txt_egpu_green
opacity = 1.0
if icon is not self._chestnut_icon:
self._chestnut_fade_time = rl.get_time()
self._chestnut_icon = icon
visible = loading or rl.get_time() - self._chestnut_fade_time < SET_SPEED_PERSISTENCE
alpha = self._chestnut_alpha_filter.update(visible)
if icon is not self._egpu_icon:
self._egpu_fade_time = rl.get_time()
self._egpu_icon = icon
alpha = self._egpu_alpha_filter.update(loading or 0 < rl.get_time() - self._egpu_fade_time < SET_SPEED_PERSISTENCE)
if alpha < 1e-2:
return
+18 -16
View File
@@ -6,7 +6,7 @@ from collections.abc import Callable
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.label import UnifiedLabel
from openpilot.system.ui.widgets.scroller import DO_ZOOM
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.common.filter_simple import BounceFilter
if TYPE_CHECKING:
@@ -125,10 +125,10 @@ class BigButton(Widget):
self._rotate_icon_t: float | None = None
self._label = UnifiedLabel(text, font_size=self._get_label_font_size(), font_weight=FontWeight.BOLD,
text_color=LABEL_COLOR, alignment_vertical=TextAlignmentVertical.BOTTOM, scroll=scroll,
text_color=LABEL_COLOR, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM, scroll=scroll,
line_height=0.9)
self._sub_label = UnifiedLabel(value, font_size=COMPLICATION_SIZE, font_weight=FontWeight.ROMAN,
text_color=COMPLICATION_GREY, alignment_vertical=TextAlignmentVertical.BOTTOM)
text_color=COMPLICATION_GREY, alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._update_label_layout()
self._load_images()
@@ -149,15 +149,11 @@ class BigButton(Widget):
def set_touch_valid_callback(self, touch_callback: Callable[[], bool]) -> None:
super().set_touch_valid_callback(lambda: touch_callback() and self._grow_animation_until is None)
def _title_width_hint(self) -> int:
# A value moves the title to the top, where it shares space with the icon
def _width_hint(self) -> int:
# A value moves the title to the top, where it shares space with the icon.
icon_size = self._txt_icon.width if self._txt_icon and self.value else 0
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - icon_size)
def _subtitle_width_hint(self) -> int:
# Bottom aligned, so it sits below the icon
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
if len(self.text) <= 18:
return 48
@@ -167,9 +163,9 @@ class BigButton(Widget):
def _update_label_layout(self):
self._label.set_font_size(self._get_label_font_size())
if self.value:
self._label.set_alignment_vertical(TextAlignmentVertical.TOP)
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
else:
self._label.set_alignment_vertical(TextAlignmentVertical.BOTTOM)
self._label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
def set_text(self, text: str):
self.text = text
@@ -232,14 +228,14 @@ class BigButton(Widget):
label_color = LABEL_COLOR if self.enabled else rl.Color(255, 255, 255, int(255 * 0.35))
self._label.set_color(label_color)
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._title_width_hint(),
label_rect = rl.Rectangle(label_x, btn_y + self.LABEL_VERTICAL_PADDING, self._width_hint(),
self._rect.height - self.LABEL_VERTICAL_PADDING * 2)
self._label.render(label_rect)
if self.value:
label_y = label_rect.y + self._label.get_content_height(int(label_rect.width))
label_y = btn_y + self.LABEL_VERTICAL_PADDING + self._label.get_content_height(self._width_hint())
sub_label_height = btn_y + self._rect.height - self.LABEL_VERTICAL_PADDING - label_y
sub_label_rect = rl.Rectangle(label_x, label_y, self._subtitle_width_hint(), sub_label_height)
sub_label_rect = rl.Rectangle(label_x, label_y, self._width_hint(), sub_label_height)
self._sub_label.render(sub_label_rect)
# ICON -------------------------------------------------------------------
@@ -316,6 +312,9 @@ class BigMultiToggle(BigToggle):
self.set_value(self._options[0])
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2 - self._txt_enabled_toggle.width)
def _handle_mouse_release(self, mouse_pos: MousePos):
super()._handle_mouse_release(mouse_pos)
cur_idx = self._options.index(self.value)
@@ -356,14 +355,17 @@ class GreyBigButton(BigButton):
self._sub_label.set_font_size(36)
self._sub_label.set_text_color(rl.Color(255, 255, 255, int(255 * 0.9)))
self._sub_label.set_font_weight(FontWeight.DISPLAY_REGULAR)
self._sub_label.set_alignment_vertical(TextAlignmentVertical.MIDDLE if not self._label.text else
TextAlignmentVertical.BOTTOM)
self._sub_label.set_alignment_vertical(rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE if not self._label.text else
rl.GuiTextAlignmentVertical.TEXT_ALIGN_BOTTOM)
self._sub_label.set_line_height(0.95)
@property
def LABEL_VERTICAL_PADDING(self):
return BigButton.LABEL_VERTICAL_PADDING if self._label.text else 18
def _width_hint(self) -> int:
return int(self._rect.width - self.LABEL_HORIZONTAL_PADDING * 2)
def _get_label_font_size(self):
return 36
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from openpilot.cereal import messaging, log
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.common.hardware import COMMA_HARDWARE
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment, TextAlignmentVertical
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
@@ -76,10 +76,10 @@ class AlertRenderer(Widget):
self.font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
# font size is set dynamically
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=TextAlignment.CENTER,
text_alignment_vertical=TextAlignmentVertical.TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=TextAlignment.CENTER,
text_alignment_vertical=TextAlignmentVertical.TOP)
self._full_text1_label = Label("", font_size=0, font_weight=FontWeight.BOLD, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
self._full_text2_label = Label("", font_size=ALERT_FONT_BIG, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
text_alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP)
def get_alert(self, sm: messaging.SubMaster) -> Alert | None:
"""Generate the current alert based on selfdrive state."""
@@ -4,7 +4,7 @@ from openpilot.cereal.visionipc import VisionStreamType
from openpilot.selfdrive.ui.onroad.cameraview import CameraView
from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer
from openpilot.selfdrive.ui.ui_state import ui_state, device
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets.label import gui_label
@@ -38,7 +38,7 @@ class CabinCameraDialog(CameraView):
tr("camera starting"),
font_size=100,
font_weight=FontWeight.BOLD,
alignment=TextAlignment.CENTER,
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
)
return -1
+9 -25
View File
@@ -24,8 +24,14 @@ ALERT_RAMP_TIME = 4 # seconds to ramp to max volume for warningImmediate
SELFDRIVE_STATE_TIMEOUT = 5 # 5 seconds
FILTER_DT = 1. / (micd.SAMPLE_RATE / micd.FFT_SAMPLES)
AMBIENT_DB = 26 # DB where MIN_VOLUME is applied
DB_SCALE = 30 # AMBIENT_DB + DB_SCALE is where MAX_VOLUME is applied
VOLUME_BASE = 20
if HARDWARE.get_device_type() == "tizi":
AMBIENT_DB = 30
VOLUME_BASE = 10
AudibleAlert = log.SelfdriveState.AudibleAlert
AudibleAlertSP = custom.SelfdriveStateSP.AudibleAlert
@@ -47,7 +53,6 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
AudibleAlert.promptDistracted: ("dm_warning.wav", None, MAX_VOLUME),
AudibleAlert.preAlert: ("pre_alert.wav", 1, MAX_VOLUME),
AudibleAlert.complete: ("milestone.wav", 1, MAX_VOLUME),
AudibleAlert.warningSoft: ("critical.wav", None, MAX_VOLUME),
AudibleAlert.warningImmediate: ("dm_critical.wav", None, MAX_VOLUME),
@@ -55,14 +60,6 @@ sound_list: dict[int, tuple[str, int | None, float]] = {
**sound_list_sp,
}
def calculate_volume_for_device(weighted_db: float, device_type: str) -> float:
ambient_db = 30 if device_type in ("mici", "tizi") else 26
volume_base = 10 if device_type in ("mici", "tizi") else 20
volume_boost = 1.5 if device_type == "mici" else 1.0
volume = ((weighted_db - ambient_db) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return min(MAX_VOLUME, volume_boost * math.pow(volume_base, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1)))
def check_selfdrive_timeout_alert(sm):
ss_missing = time.monotonic() - sm.recv_time['selfdriveState']
@@ -77,7 +74,6 @@ class Soundd(QuietMode):
def __init__(self):
super().__init__()
self.device_type = HARDWARE.get_device_type()
self.load_sounds()
self.current_alert = AudibleAlert.none
@@ -89,7 +85,6 @@ class Soundd(QuietMode):
self.selfdrive_timeout_alert = False
self.pending_stop = False
self.last_milestone_event_id = 0
self.spl_filter_weighted = FirstOrderFilter(0, 2.5, FILTER_DT, initialized=False)
@@ -169,19 +164,9 @@ class Soundd(QuietMode):
self.update_alert(AudibleAlert.none)
self.selfdrive_timeout_alert = False
def update_milestone_alert(self, sm):
if not sm.updated['assistedDrivingMilestoneState']:
return
milestone_state = sm['assistedDrivingMilestoneState']
event_id = milestone_state.event.id
if not milestone_state.enabled or event_id == 0 or event_id == self.last_milestone_event_id:
return
self.last_milestone_event_id = event_id
if self.current_alert == AudibleAlert.none and not self.enabled:
self.update_alert(AudibleAlert.complete)
def calculate_volume(self, weighted_db):
return calculate_volume_for_device(weighted_db, self.device_type)
volume = ((weighted_db - AMBIENT_DB) / DB_SCALE) * (MAX_VOLUME - MIN_VOLUME) + MIN_VOLUME
return math.pow(VOLUME_BASE, (np.clip(volume, MIN_VOLUME, MAX_VOLUME) - 1))
@retry(attempts=10, delay=3)
def get_stream(self, sd):
@@ -195,7 +180,7 @@ class Soundd(QuietMode):
import sounddevice as sd
micd.patch_sounddevice(sd)
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure', 'assistedDrivingMilestoneState'])
sm = messaging.SubMaster(['selfdriveState', 'selfdriveStateSP', 'soundPressure'])
with self.get_stream(sd) as stream:
rk = Ratekeeper(20)
@@ -213,7 +198,6 @@ class Soundd(QuietMode):
self.current_volume = self.calculate_volume(float(self.spl_filter_weighted.x))
self.get_audible_alert(sm)
self.update_milestone_alert(sm)
# Ramp up immediate warning sound over 4s
if self.current_alert == AudibleAlert.warningImmediate:
@@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details.
"""
import pyray as rl
from openpilot.selfdrive.ui.layouts.home import HomeLayout, HomeLayoutState, HEAD_BUTTON_FONT_SIZE, SPACING
from openpilot.system.ui.lib.application import gui_app, FontWeight, TextAlignment
from openpilot.system.ui.lib.application import gui_app, FontWeight
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.lib.multilang import tr, trn
from openpilot.system.ui.widgets.label import gui_label
@@ -59,7 +59,7 @@ class HomeLayoutSP(HomeLayout):
desc_size = measure_text_cached(gui_app.font(FontWeight.NORMAL), description, BRAND_FONT_SIZE)
desc_width = desc_size.x
desc_rect = rl.Rectangle(version_right - desc_width, self.header_rect.y, desc_width, self.header_rect.height)
gui_label(desc_rect, description, BRAND_FONT_SIZE, rl.WHITE, alignment=TextAlignment.RIGHT)
gui_label(desc_rect, description, BRAND_FONT_SIZE, rl.WHITE, alignment=rl.GuiTextAlignment.TEXT_ALIGN_RIGHT)
brand_size = measure_text_cached(gui_app.font(FontWeight.AUDIOWIDE), brand, BRAND_FONT_SIZE)
spacing = BRAND_DESC_SPACING if description else 0
@@ -6,7 +6,7 @@ See the LICENSE.md file in the root directory for more details.
"""
import pyray as rl
from openpilot.selfdrive.ui.ui_state import ui_state
from openpilot.system.ui.lib.application import FontWeight, TextAlignment
from openpilot.system.ui.lib.application import FontWeight
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.widgets import Widget
from openpilot.system.ui.widgets.button import Button, ButtonStyle
@@ -20,7 +20,7 @@ class SunnylinkConsentPage(Widget):
self._done_callback = done_callback
self._step = 0
self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=TextAlignment.LEFT))
self._title = self._child(Label(tr("sunnylink"), font_size=90, font_weight=FontWeight.AUDIOWIDE, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT))
self._content = [
{
@@ -43,7 +43,7 @@ class SunnylinkConsentPage(Widget):
self._primary_btn = self._child(Button("", button_style=ButtonStyle.PRIMARY, click_callback=lambda: self._handle_choice("enable")))
self._secondary_btn = self._child(Button("", button_style=ButtonStyle.NORMAL, click_callback=lambda: self._handle_choice("secondary")))
self._danger_btn = self._child(Button("", button_style=ButtonStyle.DANGER, click_callback=lambda: self._handle_choice("disable")))
self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=TextAlignment.LEFT))
self._desc = self._child(Label("", font_size=90, font_weight=FontWeight.MEDIUM, text_alignment=rl.GuiTextAlignment.TEXT_ALIGN_LEFT))
def _handle_choice(self, choice):
if choice == "enable":
@@ -10,10 +10,9 @@ import time
import pyray as rl
from openpilot.cereal import custom
from openpilot.sunnypilot.models.helpers import ACTIVE_BUNDLE_KEYS, get_selected_bundle, resolve_bundle_by_ref
from openpilot.sunnypilot.models.default_model import get_default_model
from openpilot.common.constants import CV
from openpilot.selfdrive.ui.ui_state import device, ui_state
from openpilot.selfdrive.ui.sunnypilot.model_info import big_model_state, bundles_for_source, carrying_model, default_model_name, queued_name
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.application import gui_app
from openpilot.system.ui.widgets import DialogResult, Widget
@@ -37,10 +36,7 @@ class ModelsLayout(Widget):
super().__init__()
self.model_manager = None
self.model_dialog = None
self._selection_source = None
self._downloading = False
self._verifying = False
self._last_note = None
self.last_cache_calc_time = 0
self._initialize_items()
@@ -52,24 +48,17 @@ class ModelsLayout(Widget):
self._scroller = Scroller(self.items, line_separator=True, spacing=0)
def _initialize_items(self):
self.small_model_item = ListItemSP(
title=tr("Small Model"),
self.current_model_item = ListItemSP(
title=tr("Current Model"),
description="",
action_item=ScrollingButtonAction(tr("SELECT")),
callback=lambda: self._open_source_dialog("qcom")
)
self.big_model_item = ListItemSP(
title=tr("Big Model"),
action_item=ScrollingButtonAction(tr("SELECT")),
callback=lambda: self._open_source_dialog("chestnut")
callback=self._handle_current_model_clicked
)
self.download_item = download_status_item(lambda: tr("Download") if self._downloading else tr("Model Status"))
self.refresh_item = button_item(tr("Refresh Model List"), tr("REFRESH"), "",
lambda: (ui_state.params.put("ModelManager_LastSyncTime", 0),
ui_state.params.put("ModelManager_LastSyncTime_Chestnut", 0),
gui_app.push_widget(alert_dialog(tr("Fetching Latest Models")))))
self.clear_cache_item = ListItemSP(
@@ -79,9 +68,7 @@ class ModelsLayout(Widget):
callback=self._clear_cache
)
self.cancel_download_item = button_item(lambda: tr("Cancel Verification") if self._verifying else tr("Cancel Download"),
tr("Cancel"), "",
lambda: ui_state.params.remove("ModelManager_DownloadRef"))
self.cancel_download_item = button_item(tr("Cancel Download"), tr("Cancel"), "", lambda: ui_state.params.remove("ModelManager_DownloadIndex"))
self.lane_turn_value_control = option_item_sp(tr("Adjust Lane Turn Speed"), "LaneTurnValue", 500, 2000,
tr("Set the maximum speed for lane turn desires. Default is 19 mph."),
@@ -106,7 +93,7 @@ class ModelsLayout(Widget):
1, None, True, "", style.BUTTON_ACTION_WIDTH, None, True,
lambda v: f"{v / 100:.2f} m")
self.items = [self.small_model_item, self.big_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item,
self.items = [self.current_model_item, self.cancel_download_item, self.download_item, self.refresh_item, self.clear_cache_item,
self.lane_turn_desire_toggle, self.lane_turn_value_control, self.lagd_toggle, self.delay_control, self.camera_offset]
def _update_lagd_description(self, lagd_toggle: bool):
@@ -120,16 +107,16 @@ class ModelsLayout(Widget):
desc += f"<br>{tr('Actuator Delay:')} {cp:.2f} s + {tr('Software Delay:')} {sw:.2f} s = {tr('Total Delay:')} {cp + sw:.2f} s"
self.lagd_toggle.set_description(desc)
def _is_downloading(self):
return (self.model_manager and self.model_manager.selectedBundle and
self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.downloading)
@staticmethod
def calculate_cache_size():
cache_size = 0.0
if os.path.exists(CUSTOM_MODEL_PATH):
for file in os.listdir(CUSTOM_MODEL_PATH):
try:
cache_size += os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file))
except OSError:
continue
return cache_size / (1024**2)
cache_size = sum(os.path.getsize(os.path.join(CUSTOM_MODEL_PATH, file)) for file in os.listdir(CUSTOM_MODEL_PATH)) / (1024**2)
return cache_size
def _clear_cache(self):
def _callback(response):
@@ -142,90 +129,36 @@ class ModelsLayout(Widget):
gui_app.push_widget(dialog)
def _handle_bundle_download_progress(self):
self.download_item.set_visible(False)
self.cancel_download_item.set_visible(False)
self._downloading = False
self._verifying = False
self.download_item.set_visible(True)
if not self.model_manager or (not self.model_manager.selectedBundle and not self.model_manager.activeBundle):
return
bundle = self.model_manager.selectedBundle if self._is_downloading() or (
self.model_manager.selectedBundle and self.model_manager.selectedBundle.status == custom.ModelManagerSP.DownloadStatus.failed
) else self.model_manager.activeBundle
if not bundle:
return
self.cancel_download_item.set_visible(bool(self.model_manager.selectedBundle) and ui_state.params.get("ModelManager_DownloadIndex") is not None)
if (current_time := time.monotonic()) - self.last_cache_calc_time > 0.5:
self.last_cache_calc_time = current_time
self.clear_cache_item.action_item.set_value(f"{self.calculate_cache_size():.2f} MB")
bundle = self.model_manager.selectedBundle if self.model_manager else None
progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName] if bundle else []
if not progresses or bundle.status not in (custom.ModelManagerSP.DownloadStatus.downloading,
custom.ModelManagerSP.DownloadStatus.failed):
self.download_item.action_item.update(name="", segments=self._slot_segments())
return
self.cancel_download_item.set_visible(ui_state.params.get("ModelManager_DownloadRef") is not None)
if bundle.status == custom.ModelManagerSP.DownloadStatus.downloading:
device._reset_interactive_timeout()
state = self._download_row_state(progresses, bundle.internalName)
if queued := queued_name(bundle.ref):
state["name"] += f" | {queued} {tr('queued')}"
self.download_item.action_item.update(**state)
# every bundle is a single chunked artifact now
progresses = [model.artifact.downloadProgress for model in bundle.models if model.artifact.fileName]
if not progresses:
return
self.download_item.set_visible(True)
self.download_item.action_item.update(**self._download_row_state(progresses, bundle.internalName))
self._downloading = self.download_item.action_item.downloading
ds = custom.ModelManagerSP.DownloadStatus
self._verifying = any(getattr(p.status, 'raw', p.status) == ds.verifying for p in progresses)
def _slot_segments(self):
"""small and big slots side by side; green marks the slot whose pick is actually
driving (runner-matched, so a failed Default big greens neither slot), an empty
slot shows its default."""
big_state = big_model_state()
carry_source, carry_internal, _ = carrying_model()
segments = []
for source, label in (("qcom", tr("small")), ("chestnut", tr("big"))):
if segments:
segments.append(("|", rl.GRAY, None, None))
bundle = get_selected_bundle(ui_state.params, source)
name = bundle.internalName if bundle else default_model_name(source)
color = ON_COLOR if (source == carry_source and name == carry_internal) else rl.LIGHTGRAY
name = "" + name
if source == "chestnut":
if big_state == 'failed':
color = rl.RED
elif big_state == 'loading':
color = rl.GOLD
segments.append((label, rl.GRAY, None, None))
segments.append((name, color, None, None))
return segments
@staticmethod
def _set_item_note(item, text):
# a description renders only while shown; hide before clearing or the
# empty description keeps its visible state
if text:
item.set_description(text)
item.show_description(True)
else:
item.show_description(False)
item.set_description("")
def _status_note(self) -> str:
"""The failover story for the Model Status row. One-way big -> small, and the
fallback is runner-matched: a Default big can only fall back to the Default
small (stock modeld), a custom big has no automatic fallback yet."""
if not ui_state.chestnut_present:
return ""
big_bundle = get_selected_bundle(ui_state.params, "chestnut")
big_name = big_bundle.internalName if big_bundle else default_model_name("chestnut")
big_is_default = big_bundle is None
fallback_name = default_model_name("qcom")
state = big_model_state()
if state == 'failed':
if big_is_default:
return tr("Big model unavailable, {} is driving until the next drive.").format(fallback_name)
return tr("Big model unavailable until the next drive.")
if state == 'loading':
if big_is_default:
return tr("{} drives until the big model is ready.").format(fallback_name)
return tr("Getting the big model ready.")
if big_is_default:
return tr("{} will drive. If it fails during a drive, {} takes over until the next drive.").format(big_name, fallback_name)
return tr("{} will drive when the chestnut is ready.").format(big_name)
@staticmethod
def _download_row_state(progresses, name: str) -> dict:
@@ -238,8 +171,6 @@ class ModelsLayout(Widget):
if ds.failed in statuses:
# close.png is authored black and a tint cannot lift it, hence close2
return {"name": name, "status_text": tr("download failed"), "text_color": rl.RED, "icon": "icons/close2.png"}
if ds.verifying in statuses:
return {"name": name, "downloading": True, "progress": progress, "status_text": tr("verifying")}
if ds.downloading in statuses:
return {"name": name, "downloading": True, "progress": progress}
if statuses <= {ds.downloaded, ds.cached}:
@@ -247,73 +178,65 @@ class ModelsLayout(Widget):
# circled_slash is authored grey; tinting it again only darkens it
return {"name": name, "text_color": rl.GRAY, "icon": "icons/circled_slash.png", "icon_color": rl.WHITE}
@staticmethod
def _show_reset_params_dialog():
def _callback(response):
if response == DialogResult.CONFIRM:
ui_state.params.remove("CalibrationParams")
ui_state.params.remove("LiveTorqueParameters")
msg = tr("Model download has started in the background. We suggest resetting calibration. Would you like to do that now?")
dialog = ConfirmDialog(msg, tr("Reset Calibration"), callback=_callback)
gui_app.push_widget(dialog)
def _on_model_selected(self, result):
if result != DialogResult.CONFIRM:
self.model_dialog = None
return
selected_ref = self.model_dialog.selection_ref
self.model_dialog = None
if selected_ref == "Default":
if self._selection_source in ACTIVE_BUNDLE_KEYS:
ui_state.params.remove(ACTIVE_BUNDLE_KEYS[self._selection_source])
return
if selected_bundle := self._resolve_selected_bundle(selected_ref):
ui_state.params.put("ModelManager_DownloadRef", selected_bundle.ref)
def _resolve_selected_bundle(self, ref):
source_bundles = {source: bundles_for_source(source) for source in ("qcom", "chestnut")}
resolved = resolve_bundle_by_ref(ref, source_bundles)
return resolved[0] if resolved else None
ui_state.params.remove("ModelManager_ActiveBundle")
self._show_reset_params_dialog()
elif selected_bundle := next((bundle for bundle in self.model_manager.availableBundles if bundle.ref == selected_ref), None):
ui_state.params.put("ModelManager_DownloadIndex", selected_bundle.index)
if self.model_manager.activeBundle and selected_bundle.generation != self.model_manager.activeBundle.generation:
self._show_reset_params_dialog()
self.model_dialog = None
@staticmethod
def _bundle_to_node(bundle):
return TreeNode(bundle.ref, {'display_name': bundle.displayName, 'short_name': bundle.internalName})
def _get_folders(self, favorites, bundles):
def _get_folders(self, favorites):
bundles = self.model_manager.availableBundles
folders = {}
for bundle in bundles:
folders.setdefault(next((ov_ride.value for ov_ride in bundle.overrides if ov_ride.key == "folder"), ""), []).append(bundle)
folders_list = []
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': f"{get_default_model()} (Default)",
'short_name': "Default"})])]
for folder, folder_bundles in sorted(folders.items(), key=lambda x: max((bundle.index for bundle in x[1]), default=-1), reverse=True):
folder_bundles.sort(key=lambda bundle: bundle.index, reverse=True)
name = folder + (f" - (Updated: {m.group(1)})" if folder_bundles and (m := re.search(r'\(([^)]*)\)[^(]*$', folder_bundles[0].displayName)) else "")
folders_list.append(TreeFolder(name, [self._bundle_to_node(bundle) for bundle in folder_bundles]))
if favorites and (fav_bundles := [bundle for bundle in bundles if bundle.ref in favorites]):
folders_list.insert(0, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
folders_list.insert(1, TreeFolder("Favorites", [self._bundle_to_node(bundle) for bundle in fav_bundles]))
return folders_list
def _open_source_dialog(self, source):
self._selection_source = source
def _handle_current_model_clicked(self):
favs = ui_state.params.get("ModelManager_Favs")
favorites = set(favs.split(';')) if favs else set()
folders_list = self._source_folders(favorites, source)
if not folders_list:
gui_app.push_widget(alert_dialog(tr("No models are available for this hardware yet. Connect to the internet and refresh the model list.")))
return
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, self._slot_active_ref(source), "ModelManager_Favs",
get_folders_fn=lambda favs: self._source_folders(favs, source), on_exit=self._on_model_selected)
folders_list = self._get_folders(favorites)
active_ref = self.model_manager.activeBundle.ref if self.model_manager.activeBundle else "Default"
self.model_dialog = TreeOptionDialog(tr("Select a Model"), folders_list, active_ref, "ModelManager_Favs",
get_folders_fn=self._get_folders, on_exit=self._on_model_selected)
gui_app.push_widget(self.model_dialog)
def _source_folders(self, favorites, source):
bundles = bundles_for_source(source)
if not bundles:
return []
folders_list = [TreeFolder("", [TreeNode("Default", {'display_name': default_model_name(source)})])]
folders_list.extend(self._get_folders(favorites, bundles))
return folders_list
@staticmethod
def _slot_active_ref(source: str) -> str:
bundle = get_selected_bundle(ui_state.params, source)
return bundle.ref if bundle else "Default"
def _update_state(self):
advanced_controls: bool = ui_state.params.get_bool("ShowAdvancedControls")
turn_desire: bool = ui_state.params.get_bool("LaneTurnDesire")
live_delay: bool = ui_state.params.get_bool("LagdToggle")
camera_offset: bool = ui_state.active_bundle is not None
camera_offset: bool = ui_state.params.get("ModelManager_ActiveBundle") is not None
self.lane_turn_desire_toggle.action_item.set_state(turn_desire)
self.lane_turn_value_control.set_visible(turn_desire and advanced_controls)
@@ -327,27 +250,19 @@ class ModelsLayout(Widget):
self._update_lagd_description(live_delay)
self.model_manager = ui_state.sm["modelManagerSP"]
self._handle_bundle_download_progress()
default_label = f"{get_default_model()} (Default)"
active_name = self.model_manager.activeBundle.displayName if self.model_manager and self.model_manager.activeBundle.ref else default_label
self.current_model_item.action_item.set_value(active_name)
carry_source, _, carry_display = carrying_model()
for item, item_source in ((self.small_model_item, "qcom"), (self.big_model_item, "chestnut")):
bundle = get_selected_bundle(ui_state.params, item_source)
name = bundle.displayName if bundle else default_model_name(item_source)
color = ON_COLOR if (item_source == carry_source and name == carry_display) else style.ITEM_TEXT_VALUE_COLOR
item.action_item.set_value(name, color)
note = self._status_note()
if note != self._last_note:
self._last_note = note
self._set_item_note(self.download_item, note)
offroad = ui_state.is_offroad()
self.small_model_item.action_item.set_enabled(offroad)
self.big_model_item.action_item.set_enabled(offroad)
self.small_model_item.set_description("" if offroad else tr("Only available when vehicle is off, or always offroad mode is on"))
if not ui_state.is_offroad():
self.current_model_item.action_item.set_enabled(False)
self.current_model_item.set_description(tr("Only available when vehicle is off, or always offroad mode is on"))
else:
self.current_model_item.action_item.set_enabled(True)
self.current_model_item.set_description("")
def _render(self, rect):
self._scroller.render(rect)
def show_event(self):
self._scroller.show_event()
self._last_note = None # re-expand the failover note every time the page opens
@@ -8,6 +8,7 @@ import datetime
import os
import platform
import requests
import shutil
import threading
from pathlib import Path
from time import monotonic
@@ -74,12 +75,22 @@ class OSMLayout(Widget):
def _update_map_size(self):
threading.Thread(target=self.calculate_size, daemon=True).start()
def _on_confirm_delete_maps(self):
ui_state.params.put_bool("Mapd_ClearCache", True)
def _do_delete_maps(self):
if MAP_PATH.exists():
shutil.rmtree(MAP_PATH)
for param in ("OsmDownloadedDate", "OsmLocal", "OsmLocationName", "OsmLocationTitle", "OsmStateName", "OsmStateTitle"):
ui_state.params.remove(param)
self._delete_maps_btn.action_item.set_enabled(True)
self._delete_maps_btn.action_item.set_text(tr("DELETE"))
self._update_map_size()
def _on_confirm_delete_maps(self):
self._delete_maps_btn.action_item.set_enabled(False)
self._delete_maps_btn.action_item.set_text("DELETING...")
threading.Thread(target=self._do_delete_maps).start()
def _delete_maps(self):
self._show_confirm(tr("This will delete ALL downloaded maps\n\nAre you sure you want to delete all maps?"),
tr("Yes, delete all maps"), self._on_confirm_delete_maps)

Some files were not shown because too many files have changed in this diff Show More