mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-15 16:53:41 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6d035b639 | |||
| 45bfd88e7e | |||
| 86f2e7b255 | |||
| eb7a6fc0f8 | |||
| 033ba0d973 | |||
| 32460cd5b1 | |||
| 0042016791 | |||
| f6e2fb1c33 | |||
| 43cbdc65cd | |||
| 5823cf4f84 | |||
| 023ef9419a | |||
| 374659bf43 | |||
| 9bebe42f73 | |||
| 07f755a4c7 | |||
| f4996a0529 | |||
| 2f6e2560ea | |||
| 0414843e2f | |||
| 5acb9d2c8e | |||
| 2bf8f88978 | |||
| e6e5b1c165 | |||
| b7d06aa440 | |||
| a9ccd8fedf | |||
| 6220734b2d | |||
| f3f7296776 | |||
| 9f3a2607ee | |||
| b4c3a19db1 | |||
| 04098893d9 | |||
| bc064e892f | |||
| be51bc793d | |||
| f7c65f7d6a | |||
| bd27f3b54f | |||
| d7c1827134 | |||
| 1344824746 | |||
| 75b5926f4f |
@@ -47,6 +47,7 @@ jobs:
|
||||
if [ "${{ inputs.target }}" = "big" ]; then
|
||||
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
|
||||
HF_DEFAULTS_PATH="models/defaults/big"
|
||||
ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"
|
||||
elif [ "${{ inputs.target }}" = "dm" ]; then
|
||||
ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx"
|
||||
HF_DEFAULTS_PATH="models/defaults/dm"
|
||||
@@ -116,20 +117,6 @@ jobs:
|
||||
${{ github.workspace }}/${{ env.SMALL_PKL }} \
|
||||
--device-input "*" --out-of-band --benchmark-runs 1
|
||||
|
||||
- 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 }}
|
||||
@@ -141,8 +128,7 @@ jobs:
|
||||
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/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/"
|
||||
|
||||
ONNX_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.SMALL_ONNX }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2)
|
||||
|
||||
@@ -221,19 +207,33 @@ jobs:
|
||||
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
|
||||
"
|
||||
|
||||
- name: Chunk big pkl
|
||||
- name: Compile big warp
|
||||
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')
|
||||
"
|
||||
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
|
||||
|
||||
CHESTNUT_TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot"
|
||||
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
|
||||
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_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
|
||||
CAM_W=$(echo $res | cut -d x -f1)
|
||||
CAM_H=$(echo $res | cut -d x -f2)
|
||||
STRIDE_INFO=$(python3 -c "
|
||||
import sys
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2]))
|
||||
print(f'{s},{y},{u},{sz}')
|
||||
" $CAM_W $CAM_H)
|
||||
WARP_PKL="${MODELS_DIR}/big_driving_warp_${res}_tinygrad.pkl"
|
||||
env ${CHESTNUT_TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \
|
||||
--frame ${CAM_W},${CAM_H},${STRIDE_INFO} \
|
||||
--warp-to ${MODEL_SIZE} \
|
||||
--layout yuv420 \
|
||||
--frames 2 \
|
||||
--output "${WARP_PKL}"
|
||||
done
|
||||
|
||||
- name: Prepare output
|
||||
env:
|
||||
@@ -246,10 +246,9 @@ jobs:
|
||||
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/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/"
|
||||
|
||||
PKL_LFS_SHA256=$(sha256sum "$MODELS_DIR/${PKL_BASE}" | cut -d' ' -f1)
|
||||
PKL_LFS_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.BIG_PKL }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2)
|
||||
|
||||
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
|
||||
--model-dir "$MODELS_DIR" \
|
||||
@@ -258,6 +257,8 @@ jobs:
|
||||
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \
|
||||
--onnx-sha256 "$PKL_LFS_SHA256"
|
||||
|
||||
cp "$MODELS_DIR"/big_driving_warp_* "$OUTPUT_DIR/" || true
|
||||
|
||||
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
|
||||
|
||||
- name: Upload big model artifact
|
||||
@@ -335,6 +336,8 @@ jobs:
|
||||
--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 }}"
|
||||
@@ -358,24 +361,14 @@ jobs:
|
||||
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()
|
||||
})
|
||||
matches = sorted(dm_dir.glob('dmonitoring_*_tinygrad.pkl'))
|
||||
assert len(matches) == 1, f'expected one DM pkl in {dm_dir}, found {matches}'
|
||||
pkl_name = matches[0].name
|
||||
|
||||
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)
|
||||
with open(dm_dir / pkl_name, 'rb') as f:
|
||||
while block := f.read(1024*1024):
|
||||
digest.update(block)
|
||||
|
||||
metadata = {
|
||||
'bundles': [{
|
||||
@@ -389,8 +382,7 @@ jobs:
|
||||
'type': 'chunked',
|
||||
'artifact': {
|
||||
'file_name': pkl_name,
|
||||
'download_uri': {'url': '', 'sha256': digest.hexdigest()},
|
||||
'chunks': chunks
|
||||
'download_uri': {'url': '', 'sha256': digest.hexdigest()}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
@@ -453,20 +445,6 @@ jobs:
|
||||
${{ 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
|
||||
@@ -499,8 +477,7 @@ jobs:
|
||||
- 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 }}/${{ env.DM_PKL }} dm_output/
|
||||
cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/
|
||||
|
||||
- name: Upload DM artifact
|
||||
@@ -514,4 +491,3 @@ jobs:
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
needs: build_model
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
@@ -179,18 +180,21 @@ jobs:
|
||||
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download and extract model artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
path: output
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
run: |
|
||||
id=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" -f name="$ARTIFACT_NAME" --jq '.artifacts[0].id')
|
||||
url=$(curl -sf -o /dev/null -w '%{redirect_url}' -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip")
|
||||
sudo apt-get install -y -qq aria2 > /dev/null
|
||||
aria2c -q -x16 -s16 -k8M --max-tries=5 --file-allocation=none -o model.zip "$url" && unzip -q model.zip -d output && rm model.zip
|
||||
|
||||
- name: Create models folder
|
||||
env:
|
||||
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
run: |
|
||||
mkdir -p "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}"
|
||||
cp -r output/* "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/"
|
||||
rm -f "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/artifact_name.txt"
|
||||
cp output/metadata.json "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/"
|
||||
|
||||
- name: Upload to Hugging Face
|
||||
env:
|
||||
|
||||
@@ -2,8 +2,6 @@ name: Compile warp for sunnypilot modeld
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
|
||||
jobs:
|
||||
compile_warps:
|
||||
@@ -43,13 +41,13 @@ jobs:
|
||||
|
||||
WARP_PKL="openpilot/sunnypilot/modeld_v2/models/driving_warp_${res}_tinygrad.pkl"
|
||||
echo "Compiling $WARP_PKL on QCOM"
|
||||
taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 tinygrad_repo/examples/openpilot/compile_warp.py \
|
||||
--frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${WARP_PKL}"
|
||||
taskset -c 7 env DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \
|
||||
--frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${WARP_PKL}"
|
||||
|
||||
BIG_WARP_PKL="openpilot/sunnypilot/modeld_v2/models/big_driving_warp_${res}_tinygrad.pkl"
|
||||
echo "Compiling $BIG_WARP_PKL on AMD"
|
||||
taskset -c 7 env DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 tinygrad_repo/examples/openpilot/compile_warp.py \
|
||||
--frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --layout yuv420 --frames 2 --output "${BIG_WARP_PKL}"
|
||||
taskset -c 7 env DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32 python3 openpilot/sunnypilot/modeld_v2/compile_warp.py \
|
||||
--frame ${FRAME_PARAM} --warp-to ${MODEL_SIZE} --output "${BIG_WARP_PKL}"
|
||||
done
|
||||
|
||||
- name: Re-enable powersave
|
||||
|
||||
+14
-17
@@ -1,5 +1,5 @@
|
||||
name: Download HF model chunks
|
||||
description: Resolve and download model chunks from HuggingFace in parallel
|
||||
name: Download HF model
|
||||
description: Resolve and download models from HuggingFace in parallel
|
||||
|
||||
inputs:
|
||||
hf_repo:
|
||||
@@ -19,7 +19,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download model chunks
|
||||
- name: Download models
|
||||
shell: bash
|
||||
env:
|
||||
HF_REPO: ${{ inputs.hf_repo }}
|
||||
@@ -33,7 +33,7 @@ runs:
|
||||
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
|
||||
local DEFAULTS BUNDLE ARTIFACT FILE_URL BASE_URL
|
||||
DEFAULTS=$(curl -fsSL "$JSON_URL")
|
||||
if [ -n "$TINYGRAD_REF" ]; then
|
||||
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" '
|
||||
@@ -51,26 +51,23 @@ runs:
|
||||
fi
|
||||
|
||||
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')
|
||||
FILE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url')
|
||||
BASE_URL=$(echo "$FILE_URL" | sed 's|/[^/]*$||')
|
||||
|
||||
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"
|
||||
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${FILE_URL}', safe=':/'))")
|
||||
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}" >> "$DOWNLOAD_LIST"
|
||||
|
||||
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
|
||||
elif [ "$CANONICAL" = "big_driving_tinygrad.pkl" ]; then
|
||||
for warp in big_driving_warp_1928x1208_tinygrad.pkl big_driving_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
|
||||
}
|
||||
|
||||
@@ -82,7 +79,7 @@ runs:
|
||||
done
|
||||
|
||||
TOTAL=$(wc -l < "$DOWNLOAD_LIST")
|
||||
echo "Downloading $TOTAL chunks with 8 parallel connections..."
|
||||
echo "Downloading $TOTAL files with 8 parallel connections..."
|
||||
xargs -P8 -d'\n' -I{} bash -c '
|
||||
URL="${1%% *}"
|
||||
DEST="${1#* }"
|
||||
@@ -102,56 +102,39 @@ jobs:
|
||||
cat $GITHUB_OUTPUT
|
||||
|
||||
- name: Fetch Models
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
run: |
|
||||
cd ${{ github.workspace }}/openpilot/openpilot
|
||||
fetch_lfs() {
|
||||
sudo apt-get install -y -qq aria2 > /dev/null # Parallel ranges because gh is flaky
|
||||
LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url)
|
||||
for f in "$@"; do
|
||||
grep -qs "^version https://git-lfs" "$f" || continue
|
||||
OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f")
|
||||
HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \
|
||||
-d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href')
|
||||
aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \
|
||||
-d "$(dirname "$f")" -o "$(basename "$f")" "$HREF"
|
||||
echo "$OID $f" | sha256sum -c -
|
||||
done
|
||||
}
|
||||
UPSTREAM_PKL=selfdrive/modeld/models/big_driving_tinygrad.pkl
|
||||
if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then
|
||||
ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "*.onnx" ! -name "big_*.onnx" ! -name "dmonitoring_*.onnx" 2>/dev/null | wc -l)
|
||||
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl"
|
||||
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl
|
||||
elif compgen -G "selfdrive/modeld/models/big_*.onnx" > /dev/null; then
|
||||
fetch_lfs selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl
|
||||
find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete
|
||||
elif [ -f "$UPSTREAM_PKL" ]; then
|
||||
echo "No big model ONNX tracked: converting commaai's precompiled $UPSTREAM_PKL, with the tinygrad it was built with"
|
||||
fetch_lfs "$UPSTREAM_PKL"
|
||||
find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -path "$UPSTREAM_PKL" -delete
|
||||
GITMODULES="$(git rev-parse --show-toplevel)/.gitmodules"
|
||||
TG_NAME=$(git config -f "$GITMODULES" --get-regexp '^submodule\..*\.path$' | awk '$2 == "tinygrad_repo" {sub(/^submodule\./, "", $1); sub(/\.path$/, "", $1); print $1}')
|
||||
TG_URL=$(git config -f "$GITMODULES" "submodule.$TG_NAME.url" | sed 's|^\.\./\.\./|https://github.com/|')
|
||||
echo "$TG_URL $(git rev-parse HEAD:tinygrad_repo) $(sha256sum "$UPSTREAM_PKL" | cut -d' ' -f1)" > selfdrive/modeld/models/upstream_pkl.txt
|
||||
else
|
||||
ONNX_COUNT=$(find selfdrive/modeld/models -maxdepth 1 -type f -name "big_*.onnx" 2>/dev/null | wc -l)
|
||||
fi
|
||||
if [ "$ONNX_COUNT" -eq 0 ]; then
|
||||
echo "Detected NEW stock architecture (no ONNX files tracked). Fetching from HF and skipping LFS pull."
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B)
|
||||
HF_HASH=$(echo "$COMMIT_MSG" | grep -oE '[a-f0-9]{8}' | head -n 1)
|
||||
|
||||
if [ -n "$HF_HASH" ]; then
|
||||
echo "Downloading ONNX for hash $HF_HASH from HuggingFace."
|
||||
pip install huggingface_hub
|
||||
rm -f selfdrive/modeld/models/*.onnx
|
||||
python3 - <<EOF
|
||||
import sys, os, shutil
|
||||
from huggingface_hub import HfApi, hf_hub_download
|
||||
short_hash = "$HF_HASH"
|
||||
target_dir = "selfdrive/modeld/models"
|
||||
repo = "commaai/openpilot_driving_models"
|
||||
api = HfApi()
|
||||
files = list(api.list_repo_tree(repo, repo_type="model", recursive=True))
|
||||
matching_folder = next((f.path.split("/")[0] for f in files if f.path.startswith(short_hash)), None)
|
||||
if not matching_folder:
|
||||
print(f"Could not find HF folder for hash {short_hash}")
|
||||
sys.exit(1)
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
for f in files:
|
||||
if f.path.startswith(matching_folder) and f.path.endswith(".onnx"):
|
||||
p = hf_hub_download(repo_id=repo, repo_type="model", filename=f.path)
|
||||
shutil.copy2(p, os.path.join(target_dir, os.path.basename(f.path)))
|
||||
EOF
|
||||
rm -f selfdrive/modeld/models/*.pkl
|
||||
touch selfdrive/modeld/models/new_arch.txt
|
||||
else
|
||||
echo "Could not parse HuggingFace hash from commit message."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ "${{ inputs.target_hardware }}" != "chestnut" ]; then
|
||||
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl"
|
||||
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl
|
||||
else
|
||||
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/big_*.pkl" -X ""
|
||||
find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete
|
||||
fi
|
||||
echo "::error::no big model ONNX or precompiled big_driving_tinygrad.pkl at this ref"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -lIF "version https://git-lfs.github.com/spec/v1" selfdrive/modeld/models/*.onnx selfdrive/modeld/models/*.pkl 2>/dev/null; then
|
||||
@@ -162,10 +145,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: models-${{ env.REF }}${{ inputs.artifact_suffix }}
|
||||
compression-level: 0
|
||||
path: |
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/new_arch.txt
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/upstream_pkl.txt
|
||||
if-no-files-found: error
|
||||
|
||||
build_model:
|
||||
@@ -224,11 +208,11 @@ jobs:
|
||||
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}')")
|
||||
|
||||
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
|
||||
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 OPENPILOT_HACKS=1"
|
||||
if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then
|
||||
echo "CHESTNUT build"
|
||||
export CHESTNUT=1
|
||||
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl"
|
||||
else
|
||||
echo "QCOM build"
|
||||
@@ -238,20 +222,20 @@ jobs:
|
||||
|
||||
NATIVE_PKL=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*.pkl" -print -quit)
|
||||
|
||||
if [ -f "${{ env.MODELS_DIR }}/new_arch.txt" ]; then
|
||||
echo "Detected new arch"
|
||||
DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" -print -quit)
|
||||
if [ -n "$DOWNLOADED_ONNX" ]; then
|
||||
echo "Compiling ONNX: $DOWNLOADED_ONNX"
|
||||
env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \
|
||||
"$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --out-of-band --benchmark-runs 1
|
||||
|
||||
echo "Chunking pkl"
|
||||
python3 -c "from openpilot.common.file_chunker import chunk_file, get_chunk_targets; import os; p='$OUTPUT_PKL'; chunk_file(p, get_chunk_targets(p, os.path.getsize(p)))"
|
||||
else
|
||||
echo "Failed to find downloaded ONNX."
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ]; then
|
||||
read -r TG_URL TG_SHA _ < "${{ env.MODELS_DIR }}/upstream_pkl.txt"
|
||||
echo "Converting commaai's precompiled model with tinygrad $TG_SHA"
|
||||
UPSTREAM_TG="${{ runner.temp }}/upstream_tinygrad"
|
||||
rm -rf "$UPSTREAM_TG" && git init -q "$UPSTREAM_TG" && git -C "$UPSTREAM_TG" fetch -q --depth 1 "$TG_URL" "$TG_SHA" && git -C "$UPSTREAM_TG" checkout -q FETCH_HEAD
|
||||
CONVERTED="${{ runner.temp }}/big_driving_tinygrad_plain.pkl"
|
||||
env DEV=USB+AMD PYTHONPATH="$UPSTREAM_TG:${{ github.workspace }}" taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/convert_upstream_pkl.py \
|
||||
"${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl" "$CONVERTED"
|
||||
mv "$CONVERTED" "$OUTPUT_PKL"
|
||||
elif NEW_ARCH_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}*driving*.onnx" -print -quit) &&
|
||||
[ -n "$NEW_ARCH_ONNX" ] && grep -qa new_img "$NEW_ARCH_ONNX"; then # the new architecture: one ONNX taking the warped frames as new_img
|
||||
echo "Compiling new architecture ONNX: $NEW_ARCH_ONNX"
|
||||
env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \
|
||||
"$NEW_ARCH_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1
|
||||
else
|
||||
echo "Detected onnx architecture. Routing to native compile_modeld.py."
|
||||
# Detect model type and build compile args
|
||||
@@ -317,8 +301,7 @@ jobs:
|
||||
# Copy the model files
|
||||
rsync -avm \
|
||||
--include='*.dlc' \
|
||||
--include='*.chunk*' \
|
||||
--include='*.chunkmanifest' \
|
||||
--include='/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl' \
|
||||
--exclude='*' \
|
||||
--delete-excluded \
|
||||
--chown=comma:comma \
|
||||
@@ -329,6 +312,7 @@ jobs:
|
||||
--output-dir "${{ env.OUTPUT_DIR }}" \
|
||||
--custom-name "${{ env.MODEL_NAME }}" \
|
||||
--upstream-branch "${{ inputs.upstream_branch }}" \
|
||||
$([ -f "${{ env.MODELS_DIR }}/upstream_pkl.txt" ] && echo "--onnx-sha256 $(cut -d' ' -f3 "${{ env.MODELS_DIR }}/upstream_pkl.txt")") \
|
||||
${{ inputs.is_20hz && '--is-20hz' || '' }}
|
||||
|
||||
- name: Write artifact name to file
|
||||
@@ -340,6 +324,7 @@ jobs:
|
||||
with:
|
||||
name: model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }}
|
||||
path: ${{ env.OUTPUT_DIR }}
|
||||
compression-level: 0
|
||||
|
||||
- name: Upload artifact name file
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -503,8 +503,8 @@ 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: Download models from HF
|
||||
uses: ./.github/workflows/download-hf-model
|
||||
with:
|
||||
hf_repo: sunnypilot/sunnypilot_models_v1
|
||||
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
|
||||
@@ -573,8 +573,8 @@ 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: Download models from HF
|
||||
uses: ./.github/workflows/download-hf-model
|
||||
with:
|
||||
hf_repo: sunnypilot/sunnypilot_models_v1
|
||||
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
|
||||
|
||||
@@ -47,38 +47,22 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Download Model Chunks in Parallel
|
||||
- name: Download model
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
MODEL_URL: ${{ matrix.artifact.download_uri.url }}
|
||||
MODEL_FILE: ${{ matrix.artifact.file_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/model_chunks
|
||||
echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json
|
||||
|
||||
BASE_URL="${{ matrix.artifact.download_uri.url }}"
|
||||
export BASE_DIR=$(dirname "$BASE_URL")
|
||||
|
||||
python3 -c '
|
||||
import json, os
|
||||
with open("chunks.json") as f:
|
||||
chunks = json.load(f)
|
||||
manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest"
|
||||
with open(manifest_path, "w") as f:
|
||||
f.write(str(len(chunks)))
|
||||
base_dir = os.environ["BASE_DIR"]
|
||||
hf_token = os.environ.get("HF_TOKEN", "")
|
||||
with open("/tmp/curl_config.txt", "w") as f:
|
||||
for c in chunks:
|
||||
fn = c["file_name"]
|
||||
f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n")
|
||||
if hf_token:
|
||||
f.write(f"header = \"Authorization: Bearer {hf_token}\"\n")
|
||||
'
|
||||
curl -Z --parallel-immediate --parallel-max 12 --retry 3 --retry-all-errors -s -S -f -L -K /tmp/curl_config.txt
|
||||
mkdir -p /tmp/models
|
||||
AUTH=()
|
||||
if [ -n "$HF_TOKEN" ]; then AUTH=(-H "Authorization: Bearer $HF_TOKEN"); fi
|
||||
curl --retry 3 --retry-all-errors -s -S -f -L "${AUTH[@]}" -o "/tmp/models/$MODEL_FILE" "$MODEL_URL"
|
||||
|
||||
- name: Run Model Compatibility Test
|
||||
env:
|
||||
MODEL_BASE_NAME: ${{ matrix.artifact.file_name }}
|
||||
MODEL_CHUNK_DIR: "/tmp/model_chunks"
|
||||
MODEL_DIR: "/tmp/models"
|
||||
run: |
|
||||
export PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo:$GITHUB_WORKSPACE:$PYTHONPATH"
|
||||
python3 -m pip uninstall -y tinygrad || uv pip uninstall -y tinygrad --system || true
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
[submodule "opendbc"]
|
||||
path = opendbc_repo
|
||||
url = https://github.com/sunnypilot/opendbc.git
|
||||
branch = tn
|
||||
[submodule "msgq"]
|
||||
path = msgq_repo
|
||||
url = https://github.com/sunnypilot/msgq.git
|
||||
|
||||
+1
-1
Submodule opendbc_repo updated: db952e87a0...b2acec1dc7
@@ -204,16 +204,11 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
aTarget @5 :Float32;
|
||||
events @6 :List(OnroadEventSP.Event);
|
||||
e2eAlerts @7 :E2eAlerts;
|
||||
accelController @8 :AccelController;
|
||||
|
||||
struct DynamicExperimentalControl {
|
||||
state @0 :DynamicExperimentalControlState;
|
||||
enabled @1 :Bool;
|
||||
active @2 :Bool;
|
||||
decelIntent @3 :Float32;
|
||||
curveDetected @4 :Bool;
|
||||
wantBlended @5 :Bool;
|
||||
leadVeto @6 :Bool;
|
||||
|
||||
enum DynamicExperimentalControlState {
|
||||
acc @0;
|
||||
@@ -311,17 +306,6 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
greenLightAlert @0 :Bool;
|
||||
leadDepartAlert @1 :Bool;
|
||||
}
|
||||
|
||||
struct AccelController {
|
||||
enabled @0 :Bool;
|
||||
active @1 :Bool;
|
||||
profile @2 :Profile;
|
||||
enum Profile {
|
||||
eco @0;
|
||||
normal @1;
|
||||
sport @2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnroadEventSP @0xda96579883444c35 {
|
||||
@@ -463,8 +447,6 @@ struct BackupManagerSP @0xf98d843bfd7004a3 {
|
||||
|
||||
struct CarStateSP @0xb86e6369214c01c8 {
|
||||
speedLimit @0 :Float32;
|
||||
engineOff @1 :Bool;
|
||||
engineRpm @2 :Float32;
|
||||
}
|
||||
|
||||
struct LiveMapDataSP @0xf416ec09499d9d19 {
|
||||
|
||||
@@ -192,16 +192,8 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ShowAdvancedControls", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ShowTurnSignals", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"StandstillTimer", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"SunnypilotStoppingController", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"TrueVEgoUI", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// toyota specific params
|
||||
{"ToyotaAutoHold", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaBrakeOnset", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaEnhancedBsm", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaTSS2Long", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaDriveMode", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// MADS params
|
||||
{"Mads", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"MadsMainCruiseAllowed", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
@@ -251,10 +243,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// Accel Controller profiles (Eco / Normal / Sport)
|
||||
{"AccelPersonalityEnabled", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"AccelPersonality", {PERSISTENT | BACKUP, INT, "1"}},
|
||||
|
||||
// sunnypilot model params
|
||||
{"CameraOffset", {PERSISTENT | BACKUP, FLOAT, "0.0"}},
|
||||
{"LagdToggle", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
|
||||
@@ -117,16 +117,12 @@ class TestParams(OpenpilotTestCase):
|
||||
def test_params_default_value(self):
|
||||
self.params.remove("LanguageSetting")
|
||||
self.params.remove("LongitudinalPersonality")
|
||||
self.params.remove("AccelPersonalityEnabled")
|
||||
self.params.remove("AccelPersonality")
|
||||
self.params.remove("LiveParametersV2")
|
||||
|
||||
assert self.params.get("LanguageSetting") is None
|
||||
assert self.params.get("LanguageSetting", return_default=False) is None
|
||||
assert isinstance(self.params.get("LanguageSetting", return_default=True), str)
|
||||
assert isinstance(self.params.get("LongitudinalPersonality", return_default=True), int)
|
||||
assert self.params.get("AccelPersonalityEnabled", return_default=True) is False
|
||||
assert self.params.get("AccelPersonality", return_default=True) == 1
|
||||
assert self.params.get("LiveParametersV2") is None
|
||||
assert self.params.get("LiveParametersV2", return_default=True) is None
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ from opendbc.car.structs import car
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import config_realtime_process, Priority, Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog, ForwardingHandler
|
||||
|
||||
from opendbc.car import DT_CTRL, structs
|
||||
from opendbc.car.can_definitions import CanData, CanRecvCallable, CanSendCallable
|
||||
from opendbc.car.carlog import carlog
|
||||
from opendbc.car.fw_versions import ObdCallback
|
||||
from opendbc.car.car_helpers import get_car, interfaces
|
||||
from opendbc.car.interfaces import CarInterfaceBase, RadarInterfaceBase
|
||||
from opendbc.safety import ALTERNATIVE_EXPERIENCE
|
||||
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
|
||||
@@ -123,9 +123,6 @@ class Car:
|
||||
self.RI = RI
|
||||
|
||||
self.CP.alternativeExperience = 0
|
||||
if self.params.get_bool("ToyotaAutoHold"):
|
||||
self.CP.alternativeExperience |= ALTERNATIVE_EXPERIENCE.ALLOW_AEB
|
||||
|
||||
# mads
|
||||
set_alternative_experience(self.CP, self.CP_SP, self.params)
|
||||
set_car_specific_params(self.CP, self.CP_SP, self.params)
|
||||
|
||||
@@ -19,7 +19,6 @@ IMPERIAL_INCREMENT = round(CV.MPH_TO_KPH, 1) # round here to avoid rounding err
|
||||
ButtonEvent = car.CarState.ButtonEvent
|
||||
ButtonType = car.CarState.ButtonEvent.Type
|
||||
CRUISE_LONG_PRESS = 50
|
||||
TOYOTA_VIRTUAL_CRUISE_LONG_PRESS = 65
|
||||
CRUISE_NEAREST_FUNC = {
|
||||
ButtonType.accelCruise: math.ceil,
|
||||
ButtonType.decelCruise: math.floor,
|
||||
@@ -44,30 +43,6 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
def v_cruise_initialized(self):
|
||||
return self.v_cruise_kph != V_CRUISE_UNSET
|
||||
|
||||
@property
|
||||
def software_pcm_cruise_speed(self) -> bool:
|
||||
return self.CP.brand == "toyota" and self.CP.pcmCruise and self.CP.openpilotLongitudinalControl and not self.CP_SP.pcmCruiseSpeed
|
||||
|
||||
@property
|
||||
def cruise_long_press_frames(self) -> int:
|
||||
return TOYOTA_VIRTUAL_CRUISE_LONG_PRESS if self.software_pcm_cruise_speed else CRUISE_LONG_PRESS
|
||||
|
||||
@property
|
||||
def software_pcm_cruise_initialized(self) -> bool:
|
||||
return 0 < self.v_cruise_kph < V_CRUISE_UNSET and 0 < self.v_cruise_cluster_kph < V_CRUISE_UNSET
|
||||
|
||||
def _apply_software_pcm_cruise_delta(self, delta_kph: float, is_metric: bool) -> None:
|
||||
"""Move Toyota's planner/display targets together while respecting both targets' bounds."""
|
||||
cluster_min_kph = self.v_cruise_min if is_metric else self.v_cruise_min * CV.MPH_TO_KPH
|
||||
min_delta = max(V_CRUISE_MIN - self.v_cruise_kph, cluster_min_kph - self.v_cruise_cluster_kph)
|
||||
max_delta = min(V_CRUISE_MAX - self.v_cruise_kph, V_CRUISE_MAX - self.v_cruise_cluster_kph)
|
||||
if delta_kph > 0:
|
||||
applied_delta = min(delta_kph, max(0., max_delta))
|
||||
else:
|
||||
applied_delta = max(delta_kph, min(0., min_delta))
|
||||
self.v_cruise_kph = round(self.v_cruise_kph + applied_delta, 1)
|
||||
self.v_cruise_cluster_kph = round(self.v_cruise_cluster_kph + applied_delta, 1)
|
||||
|
||||
def update_v_cruise(self, CS, enabled, is_metric):
|
||||
self.v_cruise_kph_last = self.v_cruise_kph
|
||||
|
||||
@@ -76,21 +51,11 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
_enabled = self.update_enabled_state(CS, enabled)
|
||||
|
||||
if CS.cruiseState.available:
|
||||
software_pcm_enabled = not self.CP_SP.pcmCruiseSpeed and _enabled
|
||||
if self.software_pcm_cruise_speed:
|
||||
software_pcm_enabled = software_pcm_enabled and self.software_pcm_cruise_initialized
|
||||
|
||||
if not self.CP.pcmCruise or software_pcm_enabled:
|
||||
if not self.CP.pcmCruise or (not self.CP_SP.pcmCruiseSpeed and _enabled):
|
||||
# if stock cruise is completely disabled, then we can use our own set speed logic
|
||||
self._update_v_cruise_non_pcm(CS, _enabled, is_metric)
|
||||
v_cruise_kph_before_sla = self.v_cruise_kph
|
||||
self.update_speed_limit_assist_v_cruise_non_pcm()
|
||||
if self.software_pcm_cruise_speed:
|
||||
sla_delta_kph = self.v_cruise_kph - v_cruise_kph_before_sla
|
||||
self.v_cruise_kph = v_cruise_kph_before_sla
|
||||
self._apply_software_pcm_cruise_delta(sla_delta_kph, is_metric)
|
||||
else:
|
||||
self.v_cruise_cluster_kph = self.v_cruise_kph
|
||||
self.v_cruise_cluster_kph = self.v_cruise_kph
|
||||
else:
|
||||
self.v_cruise_kph = CS.cruiseState.speed * CV.MS_TO_KPH
|
||||
self.v_cruise_cluster_kph = CS.cruiseState.speedCluster * CV.MS_TO_KPH
|
||||
@@ -120,13 +85,13 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
|
||||
for b in CS.buttonEvents:
|
||||
if b.type.raw in self.button_timers and not b.pressed:
|
||||
if self.button_timers[b.type.raw] > self.cruise_long_press_frames:
|
||||
if self.button_timers[b.type.raw] > CRUISE_LONG_PRESS:
|
||||
return # end long press
|
||||
button_type = b.type.raw
|
||||
break
|
||||
else:
|
||||
for k, timer in self.button_timers.items():
|
||||
if timer and timer % self.cruise_long_press_frames == 0:
|
||||
if timer and timer % CRUISE_LONG_PRESS == 0:
|
||||
button_type = k
|
||||
long_press = True
|
||||
break
|
||||
@@ -150,26 +115,10 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
return
|
||||
|
||||
long_press, v_cruise_delta = VCruiseHelperSP.update_v_cruise_delta(self, long_press, v_cruise_delta)
|
||||
# Toyota's canonical PCM set speed and displayed cluster set speed can differ. In
|
||||
# software-owned PCM mode, round the value the driver sees and apply the same delta
|
||||
# to both targets so the planner/cluster calibration offset remains intact.
|
||||
v_cruise_reference = self.v_cruise_cluster_kph if self.software_pcm_cruise_speed else self.v_cruise_kph
|
||||
if long_press and v_cruise_reference % v_cruise_delta != 0: # partial interval
|
||||
v_cruise_reference_new = CRUISE_NEAREST_FUNC[button_type](v_cruise_reference / v_cruise_delta) * v_cruise_delta
|
||||
if long_press and self.v_cruise_kph % v_cruise_delta != 0: # partial interval
|
||||
self.v_cruise_kph = CRUISE_NEAREST_FUNC[button_type](self.v_cruise_kph / v_cruise_delta) * v_cruise_delta
|
||||
else:
|
||||
v_cruise_reference_new = v_cruise_reference + v_cruise_delta * CRUISE_INTERVAL_SIGN[button_type]
|
||||
|
||||
if self.software_pcm_cruise_speed:
|
||||
delta_kph = v_cruise_reference_new - v_cruise_reference
|
||||
|
||||
# If SET is pressed while overriding, do not lower the target below the current speed.
|
||||
if CS.gasPressed and button_type in (ButtonType.decelCruise, ButtonType.setCruise):
|
||||
delta_kph = max(delta_kph, CS.vEgo * CV.MS_TO_KPH - self.v_cruise_kph)
|
||||
|
||||
self._apply_software_pcm_cruise_delta(delta_kph, is_metric)
|
||||
return
|
||||
|
||||
self.v_cruise_kph += v_cruise_reference_new - v_cruise_reference
|
||||
self.v_cruise_kph += v_cruise_delta * CRUISE_INTERVAL_SIGN[button_type]
|
||||
|
||||
# If set is pressed while overriding, clip cruise speed to minimum of vEgo
|
||||
if CS.gasPressed and button_type in (ButtonType.decelCruise, ButtonType.setCruise):
|
||||
@@ -178,12 +127,6 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
self.v_cruise_kph = np.clip(round(self.v_cruise_kph, 1), self.v_cruise_min, V_CRUISE_MAX)
|
||||
|
||||
def update_button_timers(self, CS, enabled):
|
||||
if self.software_pcm_cruise_speed and (not enabled or not CS.cruiseState.available or not self.software_pcm_cruise_initialized):
|
||||
for k in self.button_timers:
|
||||
self.button_timers[k] = 0
|
||||
self.button_change_states[k] = {"standstill": False, "enabled": False}
|
||||
return
|
||||
|
||||
# increment timer for buttons still pressed
|
||||
for k in self.button_timers:
|
||||
if self.button_timers[k] > 0:
|
||||
|
||||
@@ -136,7 +136,6 @@ class Controls(ControlsExt):
|
||||
# accel PID loop
|
||||
pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, self.CP_SP, CS.vEgo, CS.vCruise * CV.KPH_TO_MS)
|
||||
actuators.accel = float(self.LoC.update(CC.longActive, CS, long_plan.aTarget, long_plan.shouldStop, pid_accel_limits))
|
||||
self.long_accel_sp(actuators, CS, long_plan, pid_accel_limits) # sunnypilot hook
|
||||
|
||||
# Steering PID loop and lateral MPC
|
||||
# Reset desired curvature to current to avoid violating the limits on engage
|
||||
|
||||
@@ -64,19 +64,16 @@ class DesireHelper:
|
||||
((carstate.steeringTorque > 0 and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.steeringTorque < 0 and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
blindspot_detected = ((carstate.leftBlindspot and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(carstate.rightBlindspot and self.lane_change_direction == LaneChangeDirection.right))
|
||||
blindspot_detected = (((carstate.leftBlindspot or left_edge_detected) and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
((carstate.rightBlindspot or right_edge_detected) and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
edge_detected = ((left_edge_detected and self.lane_change_direction == LaneChangeDirection.left) or
|
||||
(right_edge_detected and self.lane_change_direction == LaneChangeDirection.right))
|
||||
|
||||
self.alc.update_lane_change(blindspot_detected, edge_detected, carstate.brakePressed, torque_applied)
|
||||
self.alc.update_lane_change(blindspot_detected, carstate.brakePressed)
|
||||
|
||||
if not one_blinker or below_lane_change_speed:
|
||||
self.lane_change_state = LaneChangeState.off
|
||||
self.lane_change_direction = LaneChangeDirection.none
|
||||
self.lane_change_timer = 0.0
|
||||
elif (torque_applied or self.alc.auto_lane_change_allowed) and not self.alc.lane_change_blocked:
|
||||
elif (torque_applied or self.alc.auto_lane_change_allowed) and not blindspot_detected:
|
||||
self.lane_change_state = LaneChangeState.laneChangeStarting
|
||||
self.lane_change_timer = 0.0
|
||||
|
||||
|
||||
@@ -35,12 +35,9 @@ def get_max_accel(v_ego):
|
||||
def get_coast_accel(pitch):
|
||||
return np.sin(pitch) * -5.65 - 0.3 # fitted from data using xx/projects/allow_throttle/compute_coast_accel.py
|
||||
|
||||
def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle,
|
||||
max_accel_override=None):
|
||||
if max_accel_override is not None:
|
||||
max_accel = max_accel_override
|
||||
else:
|
||||
max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego)
|
||||
def get_cruise_accel(e2e, v_cruise, v_ego, a_cruise_prev, angle_steers, CP, dt, accel_coast, allow_throttle):
|
||||
max_accel = ACCEL_MAX if e2e else get_max_accel(v_ego)
|
||||
|
||||
if not e2e:
|
||||
a_total_max = np.interp(v_ego, _A_TOTAL_MAX_BP, _A_TOTAL_MAX_V)
|
||||
a_y = v_ego ** 2 * angle_steers * CV.DEG_TO_RAD / (CP.steerRatio * CP.wheelbase)
|
||||
@@ -87,8 +84,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
v_ego = sm['carState'].vEgo
|
||||
v_cruise_kph = min(sm['carState'].vCruise, V_CRUISE_MAX)
|
||||
v_cruise = v_cruise_kph * CV.KPH_TO_MS
|
||||
force_decel = sm['controlsState'].forceDecel
|
||||
if force_decel:
|
||||
if sm['controlsState'].forceDecel:
|
||||
v_cruise = 0.0
|
||||
|
||||
long_control_off = sm['controlsState'].longControlState == LongCtrlState.off
|
||||
@@ -122,7 +118,6 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
self.mpc.set_weights(prev_accel_constraint, personality=sm['selfdriveState'].personality)
|
||||
self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target)
|
||||
self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality)
|
||||
self.update_dec(sm)
|
||||
|
||||
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
|
||||
self.a_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.a_solution)
|
||||
@@ -145,17 +140,9 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
|
||||
is_e2e = self.is_e2e(sm)
|
||||
|
||||
max_accel_override = self.get_max_accel_override(v_ego, sm['carStateSP'].engineOff)
|
||||
v_cruise = self.get_cruise_target_override(v_ego, v_cruise, force_decel, accel_coast if accel_coast < 0.0 else None)
|
||||
a_cruise_prev = self.a_cruise
|
||||
gated_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego, a_cruise_prev, steer_angle_without_offset,
|
||||
self.CP, self.dt, accel_coast, self.allow_throttle, max_accel_override)
|
||||
ungated_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego, a_cruise_prev, steer_angle_without_offset,
|
||||
self.CP, self.dt, accel_coast, True, max_accel_override)
|
||||
self.a_cruise = self.arbitrate_cruise_candidate(
|
||||
sm, gated_cruise, ungated_cruise, output_a_target_mpc, self.mpc.source,
|
||||
allow_throttle=self.allow_throttle, e2e=is_e2e, force_decel=force_decel,
|
||||
)
|
||||
self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego,
|
||||
self.a_cruise, steer_angle_without_offset, self.CP, self.dt,
|
||||
accel_coast, self.allow_throttle)
|
||||
cruise_should_stop = should_stop(v_ego, self.a_cruise)
|
||||
|
||||
candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc),
|
||||
@@ -166,7 +153,6 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0])
|
||||
self.output_should_stop = any(should_stop for _, _, should_stop in candidates)
|
||||
self.output_a_target = np.clip(output_a_target, ACCEL_MIN, ACCEL_MAX)
|
||||
self.accel_controller_active = self.is_accel_controller_active(force_decel)
|
||||
|
||||
self.v_desired_filter.x = self.v_desired_filter.x + self.dt * (self.output_a_target + a_prev) / 2.0
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def chestnut_action(command):
|
||||
def compile_model(onnx_path, pkl_path):
|
||||
onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath
|
||||
cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" '
|
||||
f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1')
|
||||
f'"{onnx_path}" "{target_pkl_path}" --out-of-band --benchmark-runs 1')
|
||||
lenv.Command(
|
||||
target_pkl_path,
|
||||
tinygrad_files + [onnx_path, Value(cmd)],
|
||||
|
||||
@@ -16,7 +16,6 @@ from openpilot.common.realtime import config_realtime_process
|
||||
from openpilot.common.transformations.model import dmonitoringmodel_intrinsics
|
||||
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.selfdrive.modeld.parse_model_outputs import sigmoid, safe_exp
|
||||
|
||||
SEND_RAW_PRED = os.getenv('SEND_RAW_PRED')
|
||||
@@ -28,7 +27,7 @@ class ModelState:
|
||||
output: np.ndarray
|
||||
|
||||
def __init__(self, cam_w: int, cam_h: int):
|
||||
jits = load_oob(open_file_chunked(MODEL_PKL_PATH))
|
||||
jits = load_oob(MODEL_PKL_PATH)
|
||||
self.DEV = jits['input_specs']['input_img'][2]
|
||||
self.input_shapes = jits['metadata']['input_shapes']
|
||||
self.output_slices = pickle.loads(base64.b64decode(jits['metadata']['metadata']['output_slices']))
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import io
|
||||
import os
|
||||
import pickle
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.file_chunker import get_manifest_path
|
||||
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path, open_file_chunked
|
||||
from openpilot.common.hardware import AGNOS
|
||||
from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id
|
||||
|
||||
MODELS_DIR = Path(__file__).resolve().parent / 'models'
|
||||
@@ -13,15 +16,19 @@ def modeld_pkl_path(chestnut: bool):
|
||||
prefix = 'big_' if chestnut else ''
|
||||
return MODELS_DIR / f'{prefix}driving_tinygrad.pkl'
|
||||
|
||||
def load_oob(f):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
def buffers():
|
||||
while (h := f.read(8)):
|
||||
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
|
||||
if f.readinto(pb) != pb.raw().nbytes:
|
||||
raise EOFError("incomplete model buffer")
|
||||
yield pb
|
||||
return pickle.load(io.BytesIO(opcodes), buffers=buffers())
|
||||
def load_oob(path, chestnut=False):
|
||||
from tinygrad import Context, Device
|
||||
from tinygrad.device import Buffer
|
||||
device = 'USB+AMD:LLVM' if chestnut else 'QCOM' if AGNOS else 'METAL' if sys.platform == 'darwin' else 'CPU:LLVM'
|
||||
chunks = 0 if os.path.isfile(path) else int(Path(get_manifest_path(path)).read_text())
|
||||
paths = [get_chunk_name(path, i, chunks) for i in range(chunks)] or [path]
|
||||
with Context(DEV=device), open_file_chunked(path) as f:
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
buffers, stage = Buffer(Device.DEFAULT, sum(map(os.path.getsize, paths)) - 8 - len(opcodes)).ensure_allocated(), bytearray(64 << 20)
|
||||
for offset in range(0, buffers.nbytes, len(stage)):
|
||||
n = f.readinto(piece := memoryview(stage)[:min(len(stage), buffers.nbytes - offset)])
|
||||
buffers.view(n, offset).ensure_allocated().copy_from(Buffer("PYTHON", n, initial_value=piece[:n]))
|
||||
return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers.view(*pid)})(io.BytesIO(opcodes)).load()
|
||||
|
||||
def chestnut_present() -> bool:
|
||||
for d in USB_DEVICES_PATH.glob("*"):
|
||||
|
||||
@@ -6,6 +6,7 @@ from functools import cached_property
|
||||
import os
|
||||
os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom
|
||||
from tinygrad.device import Buffer, Device
|
||||
from tinygrad.engine.realize import lower_and_compile
|
||||
from tinygrad.dtype import DType, dtypes
|
||||
from tinygrad.tensor import Tensor
|
||||
from tinygrad.helpers import round_up
|
||||
@@ -34,7 +35,6 @@ 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.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.selfdrive.modeld.constants import ModelConstants, Plan
|
||||
from openpilot.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
@@ -134,8 +134,8 @@ class FrameMeta:
|
||||
|
||||
|
||||
def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor:
|
||||
view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view)).reshape(shape)
|
||||
view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view, dtype)).reshape(shape)
|
||||
|
||||
|
||||
class ModelState(ModelStateBase):
|
||||
@@ -143,7 +143,7 @@ class ModelState(ModelStateBase):
|
||||
|
||||
def __init__(self, cam_w: int, cam_h: int, chestnut: bool):
|
||||
ModelStateBase.__init__(self)
|
||||
jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut)))
|
||||
jits = load_oob(modeld_pkl_path(chestnut), chestnut)
|
||||
self.model_device = jits['input_specs']['new_img'][2]
|
||||
self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()}
|
||||
self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']}
|
||||
@@ -158,7 +158,9 @@ class ModelState(ModelStateBase):
|
||||
self.pack_inputs()
|
||||
with open(MODELS_DIR / f'{"big_" if chestnut else ""}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl', 'rb') as f:
|
||||
self.run_warp = pickle.load(f)['run']
|
||||
self.run_warp.captured._linear = lower_and_compile(self.run_warp.captured._linear)
|
||||
self.run_model = jits['run']
|
||||
self.run_model.captured._linear = lower_and_compile(self.run_model.captured._linear)
|
||||
self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in jits['output_specs'].items()}
|
||||
for name, next_name in self.state_pairs.items():
|
||||
state = self.input_queues[name]
|
||||
@@ -230,7 +232,8 @@ def main(demo=False):
|
||||
|
||||
CHESTNUT = chestnut_present() and chestnut_compiled()
|
||||
if CHESTNUT:
|
||||
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
|
||||
from tinygrad.runtime.ops_amd import AMDDevice
|
||||
AMDDevice.wait_timeout_ms = 3000
|
||||
params = Params()
|
||||
params.put_bool("ChestnutLoading", CHESTNUT)
|
||||
params.remove("ChestnutActive")
|
||||
|
||||
@@ -11,15 +11,6 @@ from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPl
|
||||
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
|
||||
|
||||
|
||||
class PlannerSM(dict):
|
||||
def __init__(self, radar_frame: int, services: dict):
|
||||
super().__init__(services)
|
||||
self.frame = radar_frame
|
||||
self.logMonoTime = {"radarState": radar_frame}
|
||||
self.valid = {"radarState": True}
|
||||
self.alive = {"radarState": True}
|
||||
|
||||
|
||||
class Plant:
|
||||
messaging_initialized = False
|
||||
|
||||
@@ -141,7 +132,7 @@ class Plant:
|
||||
car_control.carControl.orientationNED = [0., float(pitch), 0.]
|
||||
|
||||
# ******** get controlsState messages for plotting ***
|
||||
sm = PlannerSM(self.rk.frame, {'radarState': radar.radarState,
|
||||
sm = {'radarState': radar.radarState,
|
||||
'carState': car_state.carState,
|
||||
'carControl': car_control.carControl,
|
||||
'controlsState': control.controlsState,
|
||||
@@ -150,7 +141,7 @@ class Plant:
|
||||
'modelV2': model.modelV2,
|
||||
'carStateSP': car_state_sp.carStateSP,
|
||||
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
|
||||
'gpsLocation': gps_data.gpsLocation})
|
||||
'gpsLocation': gps_data.gpsLocation}
|
||||
self.planner.update(sm)
|
||||
self.acceleration = self.planner.output_a_target
|
||||
if self.planner.output_should_stop:
|
||||
|
||||
@@ -27,12 +27,6 @@ DESCRIPTIONS = {
|
||||
"In relaxed mode sunnypilot will stay further away from lead cars. On supported cars, you can cycle through these personalities with " +
|
||||
"your steering wheel distance button."
|
||||
),
|
||||
"AccelPersonalityEnabled": tr_noop(
|
||||
"Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency braking and stopping are unchanged."
|
||||
),
|
||||
"AccelPersonality": tr_noop(
|
||||
"Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive."
|
||||
),
|
||||
"IsLdwEnabled": tr_noop(
|
||||
"Receive alerts to steer back into the lane when your vehicle drifts over a detected lane line " +
|
||||
"without a turn signal activated while driving over 31 mph (50 km/h)."
|
||||
@@ -112,24 +106,6 @@ class TogglesLayout(Widget):
|
||||
icon="speed_limit.png"
|
||||
)
|
||||
|
||||
self._accel_controller_enabled = toggle_item(
|
||||
lambda: tr("Enable Accel Controller"),
|
||||
lambda: tr(DESCRIPTIONS["AccelPersonalityEnabled"]),
|
||||
self._params.get_bool("AccelPersonalityEnabled"),
|
||||
callback=self._set_accel_controller_enabled,
|
||||
icon="speed_limit.png",
|
||||
)
|
||||
|
||||
self._accel_personality_setting = multiple_button_item(
|
||||
lambda: tr("Acceleration Profile"),
|
||||
lambda: tr(DESCRIPTIONS["AccelPersonality"]),
|
||||
buttons=[lambda: tr("Eco"), lambda: tr("Normal"), lambda: tr("Sport")],
|
||||
button_width=300,
|
||||
callback=self._set_accel_personality,
|
||||
selected_index=self._params.get("AccelPersonality", return_default=True),
|
||||
icon="speed_limit.png"
|
||||
)
|
||||
|
||||
self._toggles = {}
|
||||
self._locked_toggles = set()
|
||||
for param, (title, desc, icon, needs_restart) in self._toggle_defs.items():
|
||||
@@ -159,11 +135,9 @@ class TogglesLayout(Widget):
|
||||
|
||||
self._toggles[param] = toggle
|
||||
|
||||
# insert longitudinal personality and Accel Controller settings after NDOG toggle
|
||||
# insert longitudinal personality after NDOG toggle
|
||||
if param == "DisengageOnAccelerator":
|
||||
self._toggles["LongitudinalPersonality"] = self._long_personality_setting
|
||||
self._toggles["AccelPersonalityEnabled"] = self._accel_controller_enabled
|
||||
self._toggles["AccelPersonality"] = self._accel_personality_setting
|
||||
|
||||
self._update_experimental_mode_icon()
|
||||
self._scroller = Scroller(list(self._toggles.values()), line_separator=True, spacing=0)
|
||||
@@ -184,7 +158,6 @@ class TogglesLayout(Widget):
|
||||
|
||||
def _update_toggles(self):
|
||||
ui_state.update_params()
|
||||
accel_controller_enabled = self._params.get_bool("AccelPersonalityEnabled")
|
||||
|
||||
e2e_description = tr(
|
||||
"sunnypilot defaults to driving in chill mode. Experimental mode enables alpha-level features that aren't ready for chill mode. " +
|
||||
@@ -203,15 +176,11 @@ class TogglesLayout(Widget):
|
||||
self._toggles["ExperimentalMode"].action_item.set_enabled(True)
|
||||
self._toggles["ExperimentalMode"].set_description(e2e_description)
|
||||
self._long_personality_setting.action_item.set_enabled(True)
|
||||
self._accel_controller_enabled.action_item.set_enabled(True)
|
||||
self._accel_personality_setting.action_item.set_enabled(True)
|
||||
else:
|
||||
# no long for now
|
||||
self._toggles["ExperimentalMode"].action_item.set_enabled(False)
|
||||
self._toggles["ExperimentalMode"].action_item.set_state(False)
|
||||
self._long_personality_setting.action_item.set_enabled(False)
|
||||
self._accel_controller_enabled.action_item.set_enabled(False)
|
||||
self._accel_personality_setting.action_item.set_enabled(False)
|
||||
self._params.remove("ExperimentalMode")
|
||||
|
||||
unavailable = tr("Experimental mode is currently unavailable on this car since the car's stock ACC is used for longitudinal control.")
|
||||
@@ -234,8 +203,6 @@ class TogglesLayout(Widget):
|
||||
# refresh toggles from params to mirror external changes
|
||||
for param in self._toggle_defs:
|
||||
self._toggles[param].action_item.set_state(self._params.get_bool(param))
|
||||
self._accel_controller_enabled.action_item.set_state(accel_controller_enabled)
|
||||
self._accel_personality_setting.action_item.set_selected_button(self._params.get("AccelPersonality", return_default=True))
|
||||
|
||||
# these toggles need restart, block while engaged
|
||||
for toggle_def in self._toggle_defs:
|
||||
@@ -280,9 +247,3 @@ class TogglesLayout(Widget):
|
||||
|
||||
def _set_longitudinal_personality(self, button_index: int):
|
||||
self._params.put("LongitudinalPersonality", button_index, block=True)
|
||||
|
||||
def _set_accel_personality(self, button_index: int):
|
||||
self._params.put("AccelPersonality", button_index, block=True)
|
||||
|
||||
def _set_accel_controller_enabled(self, state: bool):
|
||||
self._params.put_bool("AccelPersonalityEnabled", state, block=True)
|
||||
|
||||
@@ -14,7 +14,6 @@ from openpilot.system.ui.lib.application import gui_app
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.settings import SettingsLayoutSP as SettingsLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.home import MiciHomeLayoutSP as MiciHomeLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad import OnroadViewContainerSP as AugmentedRoadView
|
||||
|
||||
ONROAD_DELAY = 2.5 # seconds
|
||||
|
||||
@@ -73,9 +72,6 @@ class MiciMainLayout(Scroller):
|
||||
# For scroll_to
|
||||
return self._body_onroad_layout if ui_state.is_body else self._car_onroad_layout
|
||||
|
||||
def _should_auto_scroll_to_onroad(self) -> bool:
|
||||
return True
|
||||
|
||||
def _setup_callbacks(self):
|
||||
self._home_layout.set_callbacks(
|
||||
on_settings=lambda: gui_app.push_widget(self._settings_layout),
|
||||
@@ -126,15 +122,13 @@ class MiciMainLayout(Scroller):
|
||||
|
||||
# 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:
|
||||
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._onroad_time_delay = None
|
||||
|
||||
# When car leaves standstill, pop nav stack and scroll to onroad
|
||||
CS = ui_state.sm["carState"]
|
||||
if not CS.standstill and self._prev_standstill:
|
||||
if not gui_app.sunnypilot_ui() or self._should_auto_scroll_to_onroad():
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
gui_app.pop_widgets_to(self, lambda: self._scroll_to(self._onroad_layout))
|
||||
self._prev_standstill = CS.standstill
|
||||
|
||||
def _on_interactive_timeout(self):
|
||||
|
||||
@@ -53,8 +53,6 @@ class TogglesLayoutMici(NavScroller):
|
||||
"Set speed is a maximum, not a target.\n" +
|
||||
"These are alpha features. Expect mistakes.\n" +
|
||||
"The path colors show acceleration and braking.")
|
||||
self._accel_controller_enabled = BigParamControl("enable accel controller", "AccelPersonalityEnabled")
|
||||
self._accel_personality_toggle = BigMultiParamToggle("acceleration profile", "AccelPersonality", ["eco", "normal", "sport"])
|
||||
is_metric_toggle = BigParamControl("use metric units", "IsMetric")
|
||||
ldw_toggle = BigParamControl("lane departure warnings", "IsLdwEnabled",
|
||||
description="Warn when you drift across a detected lane line.\n" +
|
||||
@@ -74,8 +72,6 @@ class TogglesLayoutMici(NavScroller):
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._personality_toggle,
|
||||
self._accel_controller_enabled,
|
||||
self._accel_personality_toggle,
|
||||
self._experimental_btn,
|
||||
is_metric_toggle,
|
||||
ldw_toggle,
|
||||
@@ -88,7 +84,6 @@ class TogglesLayoutMici(NavScroller):
|
||||
# Toggle lists
|
||||
self._refresh_toggles = (
|
||||
("ExperimentalMode", self._experimental_btn),
|
||||
("AccelPersonalityEnabled", self._accel_controller_enabled),
|
||||
("IsMetric", is_metric_toggle),
|
||||
("IsLdwEnabled", ldw_toggle),
|
||||
("AlwaysOnDM", always_on_dm_toggle),
|
||||
@@ -128,23 +123,17 @@ class TogglesLayoutMici(NavScroller):
|
||||
if ui_state.has_longitudinal_control:
|
||||
self._experimental_btn.set_visible(True)
|
||||
self._personality_toggle.set_visible(True)
|
||||
self._accel_controller_enabled.set_visible(True)
|
||||
self._accel_personality_toggle.set_visible(True)
|
||||
else:
|
||||
# no long for now
|
||||
self._experimental_btn.set_visible(False)
|
||||
self._experimental_btn.set_checked(False)
|
||||
self._personality_toggle.set_visible(False)
|
||||
self._accel_controller_enabled.set_visible(False)
|
||||
self._accel_personality_toggle.set_visible(False)
|
||||
ui_state.params.remove("ExperimentalMode")
|
||||
|
||||
# Refresh toggles from params to mirror external changes
|
||||
for key, item in self._refresh_toggles:
|
||||
item.set_checked(ui_state.params.get_bool(key))
|
||||
|
||||
self._accel_personality_toggle.refresh()
|
||||
|
||||
def _on_experimental_mode(self, state: bool):
|
||||
if state and not ui_state.params.get_bool("ExperimentalModeConfirmed"):
|
||||
# Don't show enabled state until confirm
|
||||
|
||||
@@ -154,8 +154,8 @@ class ModelRenderer(Widget, ModelRendererSP):
|
||||
self._draw_lane_lines()
|
||||
self._draw_path(sm)
|
||||
|
||||
if render_lead_indicator and radar_state:
|
||||
self._draw_lead_indicator()
|
||||
# if render_lead_indicator and radar_state:
|
||||
# self._draw_lead_indicator()
|
||||
|
||||
def _update_raw_points(self, model):
|
||||
"""Update raw 3D points from model data"""
|
||||
|
||||
@@ -400,18 +400,13 @@ class BigMultiParamToggle(BigMultiToggle):
|
||||
self._load_value()
|
||||
|
||||
def _load_value(self):
|
||||
value = self._params.get(self._param, return_default=True)
|
||||
index = value if isinstance(value, int) else 0
|
||||
self.set_value(self._options[max(0, min(index, len(self._options) - 1))])
|
||||
self.set_value(self._options[self._params.get(self._param) or 0])
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos):
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
new_idx = self._options.index(self.value)
|
||||
self._params.put(self._param, new_idx)
|
||||
|
||||
def refresh(self):
|
||||
self._load_value()
|
||||
|
||||
|
||||
class BigParamControl(BigToggle):
|
||||
def __init__(self, text: str, param: str, toggle_callback: Callable | None = None, *, description: str = "",
|
||||
|
||||
@@ -143,8 +143,7 @@ class CruiseLayout(Widget):
|
||||
self.icbm_toggle.show_description(True)
|
||||
|
||||
if has_long or has_icbm:
|
||||
software_cruise_speed = has_long and (not ui_state.CP.pcmCruise or not ui_state.CP_SP.pcmCruiseSpeed)
|
||||
self.custom_acc_toggle.action_item.set_enabled((software_cruise_speed or has_icbm) and ui_state.is_offroad())
|
||||
self.custom_acc_toggle.action_item.set_enabled(((has_long and not ui_state.CP.pcmCruise) or has_icbm) and ui_state.is_offroad())
|
||||
self.dec_toggle.action_item.set_enabled(has_long)
|
||||
self.scc_v_toggle.action_item.set_enabled(True)
|
||||
self.scc_m_toggle.action_item.set_enabled(True)
|
||||
@@ -170,7 +169,7 @@ class CruiseLayout(Widget):
|
||||
show_custom_acc_desc = True
|
||||
else:
|
||||
if has_long or has_icbm:
|
||||
if has_long and ui_state.CP.pcmCruise and ui_state.CP_SP.pcmCruiseSpeed:
|
||||
if has_long and ui_state.CP.pcmCruise:
|
||||
new_custom_acc_desc = tr(ACC_PCMCRUISE_DISABLED_DESCRIPTION)
|
||||
show_custom_acc_desc = True
|
||||
else:
|
||||
|
||||
@@ -23,7 +23,7 @@ DESCRIPTIONS = {
|
||||
'stop_and_go_hack': tr_noop(
|
||||
'sunnypilot will allow some Toyota/Lexus cars to auto resume during stop and go traffic. ' +
|
||||
'This feature is only applicable to certain models that are able to use longitudinal control. This is an alpha feature. Use at your own risk.'
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
|
||||
|
||||
|
||||
class MiciMainLayoutSP(MiciMainLayout):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
scroller = self._scroller
|
||||
scroller.scroll_panel = GuiScrollPanel2SP(scroller._horizontal, handle_out_of_bounds=not scroller._snap_items)
|
||||
|
||||
def _should_auto_scroll_to_onroad(self) -> bool:
|
||||
return not self._onroad_layout.is_on_info_panel()
|
||||
@@ -1,64 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
from collections.abc import Callable
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroller_sp import ScrollerSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.onroad.augmented_road_view import AugmentedRoadViewSP
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.onroad_info_panel import OnroadInfoPanel
|
||||
|
||||
CONFIDENCE_BALL_VISIBLE_RATIO = 0.4
|
||||
HORIZONTAL_SETTLE_PX = 5
|
||||
HORIZONTAL_RESET_RATIO = 0.5
|
||||
|
||||
|
||||
class OnroadViewContainerSP(ScrollerSP):
|
||||
def __init__(self, bookmark_callback=None):
|
||||
super().__init__(horizontal=False, snap_items=True, spacing=0, pad=0, scroll_indicator=False, edge_shadows=False)
|
||||
self.road_view = AugmentedRoadViewSP(bookmark_callback=bookmark_callback)
|
||||
self.onroad_info_panel = OnroadInfoPanel(bookmark_callback=bookmark_callback)
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self.road_view,
|
||||
self.onroad_info_panel,
|
||||
])
|
||||
self._scroller.set_reset_scroll_at_show(False)
|
||||
self._scroller.set_scrolling_enabled(lambda: abs(self.rect.x) < HORIZONTAL_SETTLE_PX)
|
||||
|
||||
for child in (self.road_view, self.onroad_info_panel):
|
||||
inner_touch_valid = child._touch_valid_callback
|
||||
child.set_touch_valid_callback(
|
||||
lambda inner=inner_touch_valid: self._touch_valid() and (inner() if inner else True)
|
||||
)
|
||||
|
||||
def set_rect(self, rect: rl.Rectangle):
|
||||
super().set_rect(rect)
|
||||
self.road_view.set_rect(rect)
|
||||
self.onroad_info_panel.set_rect(rect)
|
||||
return self
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
return self.road_view.is_swiping_left() or self.onroad_info_panel.is_swiping_left()
|
||||
|
||||
def set_click_callback(self, click_callback: Callable[[], None] | None) -> None:
|
||||
self.road_view.set_click_callback(click_callback)
|
||||
self.onroad_info_panel.set_click_callback(click_callback)
|
||||
|
||||
def is_on_info_panel(self) -> bool:
|
||||
"""True when scrolled past halfway toward onroad_info_panel (used by main layout
|
||||
to skip auto-pop-back-to-camera while user is reading the info panel)."""
|
||||
return abs(self._scroller.scroll_panel.get_offset()) > self._rect.height / 2
|
||||
|
||||
def _render(self, rect: rl.Rectangle):
|
||||
if abs(self.rect.x) > gui_app.width * HORIZONTAL_RESET_RATIO:
|
||||
self._scroller.scroll_panel.set_offset(0)
|
||||
|
||||
vertical_offset = self._scroller.scroll_panel.get_offset()
|
||||
show_ball = abs(vertical_offset) < rect.height * CONFIDENCE_BALL_VISIBLE_RATIO
|
||||
self.road_view.set_show_confidence_ball(show_ball)
|
||||
|
||||
super()._render(rect)
|
||||
@@ -1,403 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass, field
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
|
||||
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
|
||||
from openpilot.selfdrive.ui.mici.onroad.alert_renderer import AlertRenderer
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import BookmarkIcon
|
||||
|
||||
METER_TO_KM = 0.001
|
||||
METER_TO_MILE = 0.000621371
|
||||
|
||||
CONTENT_MARGIN = 16
|
||||
SPEED_LIMIT_SIGN_WIDTH = 146
|
||||
VIENNA_SIGN_SIZE = 146
|
||||
MUTCD_SIGN_HEIGHT = 178
|
||||
OFFSET_BADGE_SIZE = 50
|
||||
OFFSET_BADGE_PANEL_PADDING = 4
|
||||
MUTCD_OFFSET_SIGN_Y_SHIFT = 6
|
||||
VIENNA_BADGE_X_RATIO = 0.80
|
||||
VIENNA_BADGE_UPCOMING_X_RATIO = 0.70
|
||||
VIENNA_BADGE_Y_RATIO = -0.82
|
||||
UPCOMING_SIGN_SIZE_RATIO = 0.76
|
||||
UPCOMING_SIGN_OVERLAP_RATIO = 0.05
|
||||
UNIT_FONT_SIZE = 40
|
||||
SPEED_FONT_SIZE = 114
|
||||
ROAD_FONT_SIZE = 32
|
||||
SCC_TAG_WIDTH = 78
|
||||
SCC_TAG_HEIGHT = 30
|
||||
SCC_TAG_GAP = 5
|
||||
COLUMN_GAP = 12
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OnroadInfoPanelColors:
|
||||
white: rl.Color = rl.WHITE
|
||||
black: rl.Color = rl.BLACK
|
||||
red: rl.Color = field(default_factory=lambda: rl.Color(255, 0, 0, 255))
|
||||
green: rl.Color = field(default_factory=lambda: rl.Color(0, 255, 0, 255))
|
||||
grey: rl.Color = field(default_factory=lambda: rl.Color(190, 195, 190, 255))
|
||||
light_grey: rl.Color = field(default_factory=lambda: rl.Color(200, 200, 200, 255))
|
||||
dark_grey: rl.Color = field(default_factory=lambda: rl.Color(100, 100, 100, 255))
|
||||
bg_dark: rl.Color = field(default_factory=lambda: rl.Color(0, 0, 0, 255))
|
||||
card_bg: rl.Color = field(default_factory=lambda: rl.Color(50, 50, 50, 200))
|
||||
badge_bg: rl.Color = field(default_factory=lambda: rl.Color(60, 60, 60, 255))
|
||||
|
||||
|
||||
COLORS = OnroadInfoPanelColors()
|
||||
|
||||
|
||||
class OnroadInfoPanel(Widget):
|
||||
def __init__(self, bookmark_callback=None):
|
||||
super().__init__()
|
||||
self.speed_limit: float = 0.0
|
||||
self.speed_limit_valid: bool = False
|
||||
self.speed_limit_offset: float = 0.0
|
||||
self.next_speed_limit: float = 0.0
|
||||
self.next_speed_limit_distance: float = 0.0
|
||||
self.road_name: str = ""
|
||||
self.current_speed: float = 0.0
|
||||
self.set_speed: float = 0.0
|
||||
self.cruise_enabled: bool = False
|
||||
|
||||
self._sign_slide: float = 0.0
|
||||
|
||||
self._font_bold: rl.Font = gui_app.font(FontWeight.BOLD)
|
||||
self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD)
|
||||
self._font_medium: rl.Font = gui_app.font(FontWeight.MEDIUM)
|
||||
|
||||
self._marquee_offset: float = 0.0
|
||||
self._marquee_direction: int = 1
|
||||
self._marquee_pause_timer: float = 0.0
|
||||
self._marquee_speed: float = 40.0
|
||||
self._marquee_pause_duration: float = 1.5
|
||||
|
||||
self._alert_renderer = AlertRenderer()
|
||||
self._alert_alpha_filter = FirstOrderFilter(0, 0.05, 1 / gui_app.target_fps)
|
||||
|
||||
self._bookmark_icon = BookmarkIcon(bookmark_callback)
|
||||
|
||||
def is_swiping_left(self) -> bool:
|
||||
return self._bookmark_icon.is_swiping_left()
|
||||
|
||||
def _handle_mouse_release(self, mouse_pos: MousePos) -> None:
|
||||
# Mirror stock AugmentedRoadView: suppress click while bookmark gesture active
|
||||
if not self._bookmark_icon.interacting():
|
||||
super()._handle_mouse_release(mouse_pos)
|
||||
|
||||
def _update_state(self) -> None:
|
||||
sm = ui_state.sm
|
||||
speed_conv = CV.MS_TO_KPH if ui_state.is_metric else CV.MS_TO_MPH
|
||||
|
||||
if sm.valid["longitudinalPlanSP"]:
|
||||
lp_sp = sm["longitudinalPlanSP"]
|
||||
resolver = lp_sp.speedLimit.resolver
|
||||
self.speed_limit = resolver.speedLimit * speed_conv
|
||||
self.speed_limit_valid = resolver.speedLimitValid
|
||||
self.speed_limit_offset = resolver.speedLimitOffset * speed_conv
|
||||
|
||||
if sm.valid["liveMapDataSP"]:
|
||||
lmd = sm["liveMapDataSP"]
|
||||
self.next_speed_limit = lmd.speedLimitAhead * speed_conv
|
||||
self.next_speed_limit_distance = lmd.speedLimitAheadDistance
|
||||
self.road_name = lmd.roadName
|
||||
|
||||
if sm.updated["carState"]:
|
||||
self.current_speed = sm["carState"].vEgo * speed_conv
|
||||
|
||||
if sm.valid["carState"] and sm.valid["controlsState"]:
|
||||
self.cruise_enabled = sm["carState"].cruiseState.enabled
|
||||
v_cruise_cluster = sm["carState"].vCruiseCluster
|
||||
set_speed_kph = sm["controlsState"].vCruiseDEPRECATED if v_cruise_cluster == 0.0 else v_cruise_cluster
|
||||
self.set_speed = set_speed_kph * (METER_TO_MILE / METER_TO_KM) if not ui_state.is_metric else set_speed_kph
|
||||
|
||||
def _render(self, rect: rl.Rectangle) -> None:
|
||||
self._update_state()
|
||||
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), COLORS.bg_dark)
|
||||
|
||||
left_x = rect.x + CONTENT_MARGIN
|
||||
|
||||
if self.cruise_enabled:
|
||||
unit = tr("MAX")
|
||||
display_speed = self.set_speed
|
||||
else:
|
||||
unit = tr("km/h") if ui_state.is_metric else tr("MPH")
|
||||
display_speed = self.current_speed
|
||||
|
||||
display_speed_text = str(round(display_speed))
|
||||
if self.speed_limit_valid and display_speed > self.speed_limit:
|
||||
speed_color = COLORS.red
|
||||
else:
|
||||
speed_color = COLORS.white
|
||||
|
||||
sign_width = min(SPEED_LIMIT_SIGN_WIDTH, rect.width * 0.30)
|
||||
sign_height = VIENNA_SIGN_SIZE if ui_state.is_metric else MUTCD_SIGN_HEIGHT
|
||||
|
||||
has_upcoming_limit = self.next_speed_limit > 0 and self.next_speed_limit != self.speed_limit
|
||||
target_sign_slide = 1.0 if has_upcoming_limit else 0.0
|
||||
slide_speed = 3.0 * rl.get_frame_time()
|
||||
if self._sign_slide < target_sign_slide:
|
||||
self._sign_slide = min(self._sign_slide + slide_speed, target_sign_slide)
|
||||
elif self._sign_slide > target_sign_slide:
|
||||
self._sign_slide = max(self._sign_slide - slide_speed, target_sign_slide)
|
||||
|
||||
upcoming_width = int(sign_width * UPCOMING_SIGN_SIZE_RATIO)
|
||||
upcoming_height = int(sign_height * UPCOMING_SIGN_SIZE_RATIO)
|
||||
upcoming_reserved_width = int(upcoming_width * 0.85) + 5
|
||||
sign_x_without_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN
|
||||
sign_x_with_upcoming = rect.x + rect.width - sign_width - CONTENT_MARGIN - upcoming_reserved_width
|
||||
sign_x = sign_x_without_upcoming + (sign_x_with_upcoming - sign_x_without_upcoming) * self._sign_slide
|
||||
sign_y = rect.y + (rect.height - sign_height) / 2
|
||||
if not ui_state.is_metric and self.speed_limit_offset != 0 and self.speed_limit_valid:
|
||||
sign_y += MUTCD_OFFSET_SIGN_Y_SHIFT
|
||||
|
||||
readout_right = sign_x - COLUMN_GAP
|
||||
readout_width = max(1, readout_right - left_x)
|
||||
road_y = rect.y + rect.height - 44
|
||||
|
||||
unit_font_size = self._fit_font_size(self._font_semi_bold, unit, readout_width, 46, UNIT_FONT_SIZE, 28)
|
||||
speed_font_size = self._fit_font_size(self._font_bold, display_speed_text, readout_width, road_y - (rect.y + 54) - 8,
|
||||
SPEED_FONT_SIZE, 76)
|
||||
speed_size = measure_text_cached(self._font_bold, display_speed_text, speed_font_size)
|
||||
speed_y = min(rect.y + 54, road_y - speed_size.y - 8)
|
||||
unit_y = max(rect.y + 14, speed_y - unit_font_size - 6)
|
||||
|
||||
rl.draw_text_ex(self._font_semi_bold, unit, rl.Vector2(left_x, unit_y), unit_font_size, 0, COLORS.grey)
|
||||
rl.draw_text_ex(self._font_bold, display_speed_text, rl.Vector2(left_x, speed_y), speed_font_size, 0, speed_color)
|
||||
self._draw_road_name(left_x, road_y, readout_width)
|
||||
|
||||
if has_upcoming_limit and self._sign_slide > 0.01:
|
||||
upcoming_speed_text = str(round(self.next_speed_limit))
|
||||
distance_text = self._format_distance(self.next_speed_limit_distance)
|
||||
upcoming_x = sign_x + sign_width - int(upcoming_width * UPCOMING_SIGN_OVERLAP_RATIO)
|
||||
upcoming_y = sign_y + (sign_height - upcoming_height) / 2
|
||||
|
||||
upcoming_speed_color = COLORS.black
|
||||
if ui_state.is_metric:
|
||||
self._draw_vienna_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True)
|
||||
else:
|
||||
self._draw_mutcd_sign(upcoming_x, upcoming_y, upcoming_width, upcoming_height, upcoming_speed_text, upcoming_speed_color, is_upcoming=True)
|
||||
|
||||
distance_font_size = self._fit_font_size(self._font_medium, distance_text, upcoming_width, 30, 24, 16)
|
||||
distance_size = measure_text_cached(self._font_medium, distance_text, distance_font_size)
|
||||
rl.draw_text_ex(self._font_medium, distance_text, rl.Vector2(upcoming_x + upcoming_width / 2 - distance_size.x / 2, upcoming_y + upcoming_height),
|
||||
distance_font_size, 0, COLORS.grey)
|
||||
|
||||
self._draw_speed_limit_sign(sign_x, sign_y, sign_width, sign_height)
|
||||
|
||||
if self.speed_limit_offset != 0 and self.speed_limit_valid:
|
||||
offset_text = str(abs(round(self.speed_limit_offset)))
|
||||
badge_size = OFFSET_BADGE_SIZE
|
||||
badge_rect = self._offset_badge_rect(rect, sign_x, sign_y, sign_width, sign_height, badge_size, has_upcoming_limit)
|
||||
|
||||
if ui_state.is_metric:
|
||||
badge_radius = badge_size / 2
|
||||
badge_center_x = badge_rect.x + badge_radius
|
||||
badge_center_y = badge_rect.y + badge_radius
|
||||
rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius + 2, COLORS.dark_grey)
|
||||
rl.draw_circle(int(badge_center_x), int(badge_center_y), badge_radius, COLORS.badge_bg)
|
||||
self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_center_x, badge_center_y), COLORS.white,
|
||||
badge_size - 10, badge_size - 8, min_size=24)
|
||||
else:
|
||||
rl.draw_rectangle_rounded(badge_rect, 0.25, 10, COLORS.badge_bg)
|
||||
rl.draw_rectangle_rounded_lines_ex(badge_rect, 0.25, 10, 2, COLORS.dark_grey)
|
||||
self._draw_text_centered_fit(self._font_bold, offset_text, 32, rl.Vector2(badge_rect.x + badge_size / 2, badge_rect.y + badge_size / 2),
|
||||
COLORS.white, badge_size - 10, badge_size - 8, min_size=24)
|
||||
|
||||
scc_tag_x = min(left_x + speed_size.x + COLUMN_GAP, readout_right - SCC_TAG_WIDTH)
|
||||
scc_tag_y = speed_y + (speed_size.y - (SCC_TAG_HEIGHT * 2 + SCC_TAG_GAP)) / 2
|
||||
if scc_tag_x >= left_x + speed_size.x + 8:
|
||||
self._draw_scc_icons(scc_tag_x, scc_tag_y, readout_right)
|
||||
|
||||
self._bookmark_icon.render(rect)
|
||||
|
||||
if ui_state.started:
|
||||
alert_obj, no_alert = self._alert_renderer.will_render()
|
||||
self._alert_alpha_filter.update(0 if no_alert else 1)
|
||||
alpha = self._alert_alpha_filter.x
|
||||
if alpha > 0.01:
|
||||
rl.draw_rectangle(int(rect.x), int(rect.y), int(rect.width), int(rect.height), rl.Color(0, 0, 0, int(150 * alpha)))
|
||||
self._alert_renderer.render(rect)
|
||||
|
||||
def _draw_scc_icons(self, x: float, y: float, right_limit: float) -> None:
|
||||
sm = ui_state.sm
|
||||
if not sm.valid["longitudinalPlanSP"]:
|
||||
return
|
||||
scc = sm["longitudinalPlanSP"].smartCruiseControl
|
||||
|
||||
drawn = 0
|
||||
|
||||
for label, active in [("SCC-V", scc.vision.active), ("SCC-M", scc.map.active)]:
|
||||
if not active:
|
||||
continue
|
||||
tag_x = x
|
||||
if tag_x + SCC_TAG_WIDTH > right_limit:
|
||||
return
|
||||
tag_y = y + drawn * (SCC_TAG_HEIGHT + SCC_TAG_GAP)
|
||||
rl.draw_rectangle_rounded(rl.Rectangle(tag_x, tag_y, SCC_TAG_WIDTH, SCC_TAG_HEIGHT), 0.3, 10, COLORS.green)
|
||||
self._draw_text_centered_fit(self._font_bold, label, 18, rl.Vector2(tag_x + SCC_TAG_WIDTH / 2, tag_y + SCC_TAG_HEIGHT / 2), COLORS.black,
|
||||
SCC_TAG_WIDTH - 10, SCC_TAG_HEIGHT - 4, min_size=14)
|
||||
drawn += 1
|
||||
|
||||
def _draw_speed_limit_sign(self, x: float, y: float, sign_width: float, sign_height: float) -> None:
|
||||
speed_str = str(round(self.speed_limit)) if self.speed_limit_valid and self.speed_limit > 0 else "--"
|
||||
speed_color = COLORS.black if not self.speed_limit_valid or self.current_speed <= self.speed_limit else COLORS.red
|
||||
|
||||
if ui_state.is_metric:
|
||||
self._draw_vienna_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
|
||||
else:
|
||||
self._draw_mutcd_sign(x, y, sign_width, sign_height, speed_str, speed_color, is_upcoming=False)
|
||||
|
||||
def _draw_road_name(self, x: float, y: float, width: float) -> None:
|
||||
if width <= 0:
|
||||
return
|
||||
|
||||
road_display = self.road_name if self.road_name else "--"
|
||||
font_size = self._fit_font_size(self._font_semi_bold, road_display, width, 38, ROAD_FONT_SIZE, 28)
|
||||
road_size = measure_text_cached(self._font_semi_bold, road_display, font_size)
|
||||
text_width = road_size.x
|
||||
|
||||
if text_width <= width:
|
||||
self._marquee_offset = 0.0
|
||||
self._marquee_direction = 1
|
||||
self._marquee_pause_timer = 0.0
|
||||
rl.draw_text_ex(self._font_semi_bold, road_display, rl.Vector2(x, y), font_size, 0, COLORS.white)
|
||||
else:
|
||||
overflow = text_width - width
|
||||
dt = rl.get_frame_time()
|
||||
|
||||
if self._marquee_pause_timer > 0:
|
||||
self._marquee_pause_timer -= dt
|
||||
else:
|
||||
self._marquee_offset += self._marquee_direction * self._marquee_speed * dt
|
||||
|
||||
if self._marquee_offset >= overflow:
|
||||
self._marquee_offset = overflow
|
||||
self._marquee_direction = -1
|
||||
self._marquee_pause_timer = self._marquee_pause_duration
|
||||
elif self._marquee_offset <= 0:
|
||||
self._marquee_offset = 0
|
||||
self._marquee_direction = 1
|
||||
self._marquee_pause_timer = self._marquee_pause_duration
|
||||
|
||||
rl.begin_scissor_mode(int(x), int(y), int(width), int(road_size.y + 4))
|
||||
text_pos = rl.Vector2(x - self._marquee_offset, y)
|
||||
rl.draw_text_ex(self._font_semi_bold, road_display, text_pos, font_size, 0, COLORS.white)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_vienna_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
|
||||
center = rl.Vector2(x + width / 2, y + height / 2)
|
||||
outer_radius = min(width, height) / 2
|
||||
|
||||
rl.draw_circle_v(center, outer_radius, COLORS.white)
|
||||
ring_width = outer_radius * 0.18
|
||||
rl.draw_ring(center, outer_radius - ring_width, outer_radius, 0, 360, 36, COLORS.red)
|
||||
|
||||
font_size = outer_radius * (0.7 if len(speed_str) >= 3 else 0.9)
|
||||
self._draw_text_centered_fit(self._font_bold, speed_str, int(font_size), center, speed_color, width * 0.72, height * 0.50, min_size=24)
|
||||
|
||||
def _draw_mutcd_sign(self, x: float, y: float, width: float, height: float, speed_str: str, speed_color: rl.Color, is_upcoming: bool = False) -> None:
|
||||
sign_rect = rl.Rectangle(x, y, width, height)
|
||||
rl.draw_rectangle_rounded(sign_rect, 0.35, 10, COLORS.white)
|
||||
|
||||
inset = max(4, width * 0.05)
|
||||
inner_rect = rl.Rectangle(x + inset, y + inset, width - inset * 2, height - inset * 2)
|
||||
outer_radius = 0.35 * width / 2.0
|
||||
inner_radius = outer_radius - inset
|
||||
inner_roundness = inner_radius / (inner_rect.width / 2.0)
|
||||
rl.draw_rectangle_rounded_lines_ex(inner_rect, inner_roundness, 10, 3, COLORS.black)
|
||||
|
||||
mid_x = x + width / 2
|
||||
label_size = max(18, int(width * 0.26))
|
||||
if is_upcoming:
|
||||
self._draw_text_centered_fit(self._font_bold, tr("AHEAD"), int(width * 0.34), rl.Vector2(mid_x, y + height * 0.28), COLORS.black,
|
||||
width * 0.94, height * 0.32, min_size=20)
|
||||
else:
|
||||
self._draw_text_centered_fit(self._font_bold, tr("SPEED"), label_size, rl.Vector2(mid_x, y + height * 0.20), COLORS.black,
|
||||
width * 0.84, height * 0.24, min_size=16)
|
||||
self._draw_text_centered_fit(self._font_bold, tr("LIMIT"), label_size, rl.Vector2(mid_x, y + height * 0.40), COLORS.black,
|
||||
width * 0.84, height * 0.24, min_size=16)
|
||||
|
||||
speed_font_size = int(width * 0.60) if len(speed_str) >= 3 else int(width * 0.72)
|
||||
self._draw_text_centered_fit(self._font_bold, speed_str, speed_font_size, rl.Vector2(mid_x, y + height * 0.72), speed_color,
|
||||
width * 0.90, height * 0.52, min_size=32)
|
||||
|
||||
def _draw_text_centered(self, font, text, size, pos_center, color):
|
||||
sz = measure_text_cached(font, text, size)
|
||||
rl.draw_text_ex(font, text, rl.Vector2(pos_center.x - sz.x / 2, pos_center.y - sz.y / 2), size, 0, color)
|
||||
|
||||
def _draw_text_centered_fit(self, font, text, size, pos_center, color, max_width: float, max_height: float, min_size: int = 10):
|
||||
size = self._fit_font_size(font, text, max_width, max_height, size, min_size)
|
||||
self._draw_text_centered(font, text, size, pos_center, color)
|
||||
|
||||
def _fit_font_size(self, font, text: str, max_width: float, max_height: float, max_size: int | float, min_size: int) -> int:
|
||||
size = int(max_size)
|
||||
while size > min_size:
|
||||
text_size = measure_text_cached(font, text, size)
|
||||
if text_size.x <= max_width and text_size.y <= max_height:
|
||||
return size
|
||||
size -= 2
|
||||
return min_size
|
||||
|
||||
def _offset_badge_rect(self, panel_rect: rl.Rectangle, sign_x: float, sign_y: float, sign_width: float, sign_height: float,
|
||||
badge_size: float, has_upcoming_limit: bool) -> rl.Rectangle:
|
||||
if ui_state.is_metric:
|
||||
radius = min(sign_width, sign_height) / 2
|
||||
center_x = sign_x + sign_width / 2
|
||||
center_y = sign_y + sign_height / 2
|
||||
badge_x_ratio = VIENNA_BADGE_UPCOMING_X_RATIO if has_upcoming_limit else VIENNA_BADGE_X_RATIO
|
||||
badge_center_x = center_x + radius * badge_x_ratio
|
||||
badge_center_y = center_y + radius * VIENNA_BADGE_Y_RATIO
|
||||
badge_x = badge_center_x - badge_size / 2
|
||||
badge_y = badge_center_y - badge_size / 2
|
||||
else:
|
||||
badge_x = sign_x + sign_width - badge_size * 0.45
|
||||
badge_y = sign_y - badge_size * 0.75
|
||||
|
||||
return rl.Rectangle(
|
||||
self._clamp(
|
||||
badge_x,
|
||||
panel_rect.x + OFFSET_BADGE_PANEL_PADDING,
|
||||
panel_rect.x + panel_rect.width - badge_size - OFFSET_BADGE_PANEL_PADDING,
|
||||
),
|
||||
self._clamp(
|
||||
badge_y,
|
||||
panel_rect.y + OFFSET_BADGE_PANEL_PADDING,
|
||||
panel_rect.y + panel_rect.height - badge_size - OFFSET_BADGE_PANEL_PADDING,
|
||||
),
|
||||
badge_size,
|
||||
badge_size,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _clamp(value: float, min_value: float, max_value: float) -> float:
|
||||
return max(min_value, min(max_value, value))
|
||||
|
||||
def _format_distance(self, distance: float) -> str:
|
||||
if ui_state.is_metric:
|
||||
if distance < 50:
|
||||
return tr("Near")
|
||||
if distance >= 1000:
|
||||
return f"{distance * METER_TO_KM:.1f}" + tr("km")
|
||||
if distance < 200:
|
||||
rounded = max(10, int(distance / 10) * 10)
|
||||
else:
|
||||
rounded = int(distance / 100) * 100
|
||||
return str(rounded) + tr("m")
|
||||
else:
|
||||
distance_mi = distance * METER_TO_MILE
|
||||
if distance_mi < 0.1:
|
||||
return tr("Near")
|
||||
return f"{distance_mi:.1f}" + tr("mi")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from openpilot.selfdrive.ui.mici.onroad.augmented_road_view import AugmentedRoadView
|
||||
|
||||
|
||||
class _SuppressedConfidenceBall:
|
||||
def render(self, *_):
|
||||
pass
|
||||
|
||||
|
||||
class AugmentedRoadViewSP(AugmentedRoadView):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._show_confidence_ball: bool = True
|
||||
self._real_confidence_ball = self._confidence_ball
|
||||
self._confidence_ball = _SuppressedConfidenceBall()
|
||||
|
||||
def set_show_confidence_ball(self, show: bool) -> None:
|
||||
self._show_confidence_ball = show
|
||||
|
||||
def _render(self, _) -> None:
|
||||
super()._render(_)
|
||||
if self._show_confidence_ball:
|
||||
self._real_confidence_ball.render(self.rect)
|
||||
@@ -1,83 +0,0 @@
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.system.ui.lib.application import MouseEvent, MousePos, gui_app
|
||||
from openpilot.system.ui.lib.scroll_panel2 import ScrollState
|
||||
from openpilot.system.ui.widgets import Widget
|
||||
from openpilot.system.ui.widgets import scroller as scroller_mod
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
|
||||
|
||||
|
||||
class DummyScrollIndicator:
|
||||
def update(self, *_) -> None:
|
||||
pass
|
||||
|
||||
def render(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class DummyWidget(Widget):
|
||||
def __init__(self, rect: rl.Rectangle):
|
||||
super().__init__()
|
||||
self.set_rect(rect)
|
||||
|
||||
def _render(self, _) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _mouse_event(x: float, y: float, *, pressed: bool = False, released: bool = False,
|
||||
down: bool = True, t: float = 0.0) -> MouseEvent:
|
||||
return MouseEvent(MousePos(x, y), 0, pressed, released, down, t)
|
||||
|
||||
|
||||
class TestScrollerSP(OpenpilotTestCase):
|
||||
def test_vertical_snap_items_are_supported(self, monkeypatch):
|
||||
monkeypatch.setattr(scroller_mod, "ScrollIndicator", DummyScrollIndicator)
|
||||
|
||||
scroller = scroller_mod._Scroller([], horizontal=False, snap_items=True, scroll_indicator=False)
|
||||
scroller.set_rect(rl.Rectangle(0, 0, 100, 100))
|
||||
scroller.scroll_panel.set_offset(-60)
|
||||
|
||||
captured_snap_target = None
|
||||
|
||||
def update(_, __, snap_target=None):
|
||||
nonlocal captured_snap_target
|
||||
captured_snap_target = snap_target
|
||||
return scroller.scroll_panel.get_offset()
|
||||
|
||||
monkeypatch.setattr(scroller.scroll_panel, "update", update)
|
||||
|
||||
visible_items: list[Widget] = [
|
||||
DummyWidget(rl.Rectangle(0, -60, 100, 100)),
|
||||
DummyWidget(rl.Rectangle(0, 40, 100, 100)),
|
||||
]
|
||||
scroller._get_scroll(visible_items, 200)
|
||||
|
||||
assert captured_snap_target == -100
|
||||
|
||||
def test_scroll_panel_sp_rejects_orthogonal_drags(self, monkeypatch):
|
||||
panel = GuiScrollPanel2SP(horizontal=True)
|
||||
bounds = rl.Rectangle(0, 0, 100, 100)
|
||||
|
||||
monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(10, 10, pressed=True, t=1.0)])
|
||||
panel.update(bounds, 200)
|
||||
assert panel.state == ScrollState.PRESSED
|
||||
|
||||
monkeypatch.setattr(gui_app, "_mouse_events", [_mouse_event(23, 60, t=1.1)])
|
||||
panel.update(bounds, 200)
|
||||
|
||||
assert panel.state == ScrollState.STEADY
|
||||
assert panel.get_offset() == 0
|
||||
|
||||
def test_scroll_panel_sp_can_disable_out_of_bounds_handling(self, monkeypatch):
|
||||
panel = GuiScrollPanel2SP(horizontal=False, handle_out_of_bounds=False)
|
||||
bounds = rl.Rectangle(0, 0, 100, 100)
|
||||
monkeypatch.setattr(gui_app, "_mouse_events", [])
|
||||
|
||||
panel.set_offset(20)
|
||||
panel.update(bounds, 200)
|
||||
assert panel.get_offset() == 0
|
||||
|
||||
panel.set_offset(-150)
|
||||
panel.update(bounds, 200)
|
||||
assert panel.get_offset() == -100
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import MouseEvent
|
||||
from openpilot.system.ui.lib.scroll_panel2 import GuiScrollPanel2, ScrollState
|
||||
|
||||
|
||||
class GuiScrollPanel2SP(GuiScrollPanel2):
|
||||
"""Scroll panel behavior for nested Mici pagers."""
|
||||
|
||||
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
|
||||
super().__init__(horizontal, handle_out_of_bounds=handle_out_of_bounds)
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
|
||||
content_size: float) -> None:
|
||||
state_before_update = self._state
|
||||
super()._handle_mouse_event(mouse_event, bounds, bounds_size, content_size)
|
||||
|
||||
if self._state == ScrollState.MANUAL_SCROLL and state_before_update == ScrollState.PRESSED and \
|
||||
self._initial_click_event is not None:
|
||||
drag_x = abs(mouse_event.pos.x - self._initial_click_event.pos.x)
|
||||
drag_y = abs(mouse_event.pos.y - self._initial_click_event.pos.y)
|
||||
primary_drag = drag_x if self._horizontal else drag_y
|
||||
cross_drag = drag_y if self._horizontal else drag_x
|
||||
if cross_drag > primary_drag:
|
||||
self._state = ScrollState.STEADY
|
||||
self._velocity = 0.0
|
||||
self._velocity_buffer.clear()
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from openpilot.system.ui.widgets.scroller import Scroller
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.widgets.scroll_panel_sp import GuiScrollPanel2SP
|
||||
|
||||
|
||||
class ScrollerSP(Scroller):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
inner = self._scroller
|
||||
inner.scroll_panel = GuiScrollPanel2SP(inner._horizontal, handle_out_of_bounds=not inner._snap_items)
|
||||
@@ -10,9 +10,6 @@ from openpilot.selfdrive.ui.layouts.main import MainLayout
|
||||
from openpilot.selfdrive.ui.mici.layouts.main import MiciMainLayout
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
|
||||
if gui_app.sunnypilot_ui():
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.main import MiciMainLayoutSP as MiciMainLayout
|
||||
|
||||
BIG_UI = gui_app.big_ui()
|
||||
|
||||
|
||||
|
||||
@@ -7,14 +7,12 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import codecs
|
||||
import gc
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
@@ -22,8 +20,8 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets, open_file_chunked
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob, load_oob
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, patch_fetch_fw, read_parts, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
|
||||
@@ -36,34 +34,7 @@ from tinygrad.tensor import Tensor
|
||||
|
||||
os.environ['GMMU'] = '0'
|
||||
|
||||
def _patch_tinygrad_fetch_fw():
|
||||
try:
|
||||
import hashlib
|
||||
import pathlib
|
||||
import zstandard
|
||||
from tinygrad import helpers
|
||||
_orig_fetch_fw = helpers.fetch_fw
|
||||
def fetch_fw(path, name, sha256):
|
||||
p = pathlib.Path(f"/lib/firmware/{path}/{name}.zst")
|
||||
if p.is_file():
|
||||
blob = zstandard.ZstdDecompressor().stream_reader(p.read_bytes()).read()
|
||||
if hashlib.sha256(blob).hexdigest() == sha256:
|
||||
return blob
|
||||
return _orig_fetch_fw(path, name, sha256)
|
||||
helpers.fetch_fw = fetch_fw
|
||||
except ImportError:
|
||||
pass
|
||||
_patch_tinygrad_fetch_fw()
|
||||
|
||||
def _patch_ops_serialization():
|
||||
try:
|
||||
from tinygrad.uop.ops import Ops
|
||||
def __reduce_ex__(self, proto):
|
||||
return (getattr, (self.__class__, self.name))
|
||||
Ops.__reduce_ex__ = __reduce_ex__
|
||||
except ImportError:
|
||||
pass
|
||||
_patch_ops_serialization()
|
||||
patch_fetch_fw()
|
||||
|
||||
MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy')
|
||||
WARP_INPUTS = ['tfm', 'big_tfm']
|
||||
@@ -72,6 +43,7 @@ POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'
|
||||
class MetadataOnnxPBParser(OnnxPBParser):
|
||||
def _parse_ModelProto(self) -> dict:
|
||||
obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []}
|
||||
self.opset_imports = {}
|
||||
for fid, wire_type in self._parse_message(self.reader.len):
|
||||
match fid:
|
||||
case 7:
|
||||
@@ -150,6 +122,10 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu
|
||||
return shapes, sizes
|
||||
|
||||
|
||||
def host_view(tensor: Tensor) -> np.ndarray: # QCOM -> CPU round trip of tfm takes 5ms longer than host memory view
|
||||
return np.frombuffer(tensor._buffer()._host_mv(), dtype=np.float32).reshape(tensor.shape)
|
||||
|
||||
|
||||
def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = Device.DEFAULT,
|
||||
is_supercombo: bool = False) -> tuple[dict, dict]:
|
||||
road_key, _ = _detect_vision_keys(input_shapes)
|
||||
@@ -185,7 +161,7 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D
|
||||
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
|
||||
'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]),
|
||||
dtype=np.float32), device=device).contiguous().realize(),
|
||||
'packed_npy_inputs': Tensor(packed_npy_inputs, device=device if os.getenv('CHESTNUT') else 'NPY').realize(),
|
||||
'packed_npy_inputs': Tensor(packed_npy_inputs, device=device).realize(),
|
||||
}
|
||||
|
||||
if features_buffer:
|
||||
@@ -197,9 +173,11 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D
|
||||
for key in ('tfm', 'big_tfm'):
|
||||
if key not in npy_arrays:
|
||||
npy_arrays[key] = np.eye(3, dtype=np.float32)
|
||||
queues[key] = Tensor(npy_arrays[key], device='NPY').realize()
|
||||
queues[key] = Tensor(npy_arrays[key], device=device).realize()
|
||||
|
||||
return queues, npy_arrays
|
||||
packed = host_view(queues['packed_npy_inputs'])
|
||||
npy_arrays |= {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed, split_indices) if sizes else [], strict=True)}
|
||||
return queues, npy_arrays | {k: host_view(queues[k]) for k in ('tfm', 'big_tfm')}
|
||||
|
||||
|
||||
def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict,
|
||||
@@ -221,8 +199,8 @@ def make_warp_queues(device=Device.DEFAULT):
|
||||
'tfm': np.zeros((3, 3), dtype=np.float32),
|
||||
'big_tfm': np.zeros((3, 3), dtype=np.float32),
|
||||
}
|
||||
queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()}
|
||||
return queues, npy
|
||||
queues = {k: Tensor(v, device=device).realize() for k, v in npy.items()}
|
||||
return queues, {k: host_view(v) for k, v in queues.items()}
|
||||
|
||||
|
||||
def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict):
|
||||
@@ -282,8 +260,19 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
|
||||
return run_policy
|
||||
|
||||
|
||||
class _CheckFrameRecorder:
|
||||
def __init__(self, jit):
|
||||
self.jit, self.frame = jit, None
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if self.frame is not None or args or self.jit.cnt < 2:
|
||||
return self.jit(*args, **kwargs)
|
||||
self.frame, result = capture_frame(self.jit, kwargs, None)
|
||||
return result
|
||||
|
||||
|
||||
def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_random_inputs=None,
|
||||
benchmark_runs: int = 1, clear_refs=None, return_loaded: bool = True):
|
||||
benchmark_runs: int = 1, clear_refs=None):
|
||||
if callable(input_keys_or_make_inputs) and make_queues is None:
|
||||
fn = jit_or_fn
|
||||
make_inputs = input_keys_or_make_inputs
|
||||
@@ -334,22 +323,23 @@ def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_ran
|
||||
|
||||
print('capture + replay')
|
||||
gc.collect()
|
||||
test_val = run_eval(jit, 42, 3)
|
||||
print(f'pickle round trip ({benchmark_runs} runs per seed)')
|
||||
with tempfile.TemporaryFile(dir=".") as f:
|
||||
dump_oob(jit, f)
|
||||
if clear_refs:
|
||||
clear_refs()
|
||||
gc.collect()
|
||||
f.seek(0)
|
||||
loaded_jit = load_oob(f)
|
||||
recorder = _CheckFrameRecorder(jit)
|
||||
with record_kernel_linear() as recorded:
|
||||
test_val = run_eval(recorder, 42, 3)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
part = serialize_jit(jit, recorded[0], recorder.frame)
|
||||
print(f'plain artifact round trip ({benchmark_runs} runs per seed)')
|
||||
if clear_refs:
|
||||
clear_refs()
|
||||
gc.collect()
|
||||
loaded_jit = PlainJit(part[0], memoryview(part[1]))
|
||||
|
||||
for seed in (42, 43):
|
||||
reference = test_val if seed == 42 else run_eval(jit_or_fn, seed, 1)
|
||||
actual = run_eval(loaded_jit, seed, benchmark_runs)
|
||||
for ref, val in zip(reference, actual, strict=True):
|
||||
np.testing.assert_array_equal(ref, val)
|
||||
return loaded_jit if return_loaded else jit
|
||||
return part
|
||||
|
||||
|
||||
def _parse_size(size_str: str) -> tuple[int, int]:
|
||||
@@ -357,16 +347,6 @@ def _parse_size(size_str: str) -> tuple[int, int]:
|
||||
return int(width), int(height)
|
||||
|
||||
|
||||
def read_file_chunked_to_disk(path):
|
||||
if not path:
|
||||
return None
|
||||
tmp_path = f'{path}.unchunked'
|
||||
with open(tmp_path, 'wb') as f, open_file_chunked(path) as src:
|
||||
shutil.copyfileobj(src, f)
|
||||
atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]:
|
||||
runners, keys = [], []
|
||||
for name, onnx_arg in [('policy', args.policy_onnx), ('off_policy', args.off_policy_onnx), ('on_policy', args.on_policy_onnx)]:
|
||||
@@ -387,10 +367,7 @@ def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_ru
|
||||
warp = None
|
||||
compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs,
|
||||
benchmark_runs=benchmark_runs, clear_refs=cleanup_warp)
|
||||
|
||||
result_data = (compiled_jit, Device.DEFAULT)
|
||||
with open(result_path, "wb") as f:
|
||||
dump_oob(result_data, f)
|
||||
write_bundle(result_path, {(cam_w, cam_h): compiled_jit}, {'device': Device.DEFAULT})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -421,12 +398,6 @@ if __name__ == "__main__":
|
||||
model_w, model_h = args.model_size
|
||||
output_data = {}
|
||||
|
||||
args.vision_onnx = read_file_chunked_to_disk(args.vision_onnx)
|
||||
args.policy_onnx = read_file_chunked_to_disk(args.policy_onnx)
|
||||
args.off_policy_onnx = read_file_chunked_to_disk(args.off_policy_onnx)
|
||||
args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx)
|
||||
args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx)
|
||||
|
||||
is_unified_supercombo = False
|
||||
if args.model_type == 'supercombo':
|
||||
assert args.supercombo_onnx
|
||||
@@ -449,8 +420,10 @@ if __name__ == "__main__":
|
||||
frame_copy_size=frame_copy_size)
|
||||
warp = stock.make_warp(nv12, model_w, model_h)
|
||||
run_model_jit = TinyJit(stock.make_run_model(warp, run_policy, model_metadata, frame_copy_size), prune=True)
|
||||
compiled_jit = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs, return_loaded=False)
|
||||
output_data[(cam_w, cam_h)] = compiled_jit
|
||||
write_bundle(part_path := f"{args.output}.{cam_w}x{cam_h}", {(cam_w, cam_h): compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues,
|
||||
benchmark_runs=args.benchmark_runs)})
|
||||
output_data[(cam_w, cam_h)] = read_parts(part_path)[0][(cam_w, cam_h)]
|
||||
os.remove(part_path)
|
||||
gc.collect()
|
||||
del model_runner, run_policy
|
||||
gc.collect()
|
||||
@@ -518,18 +491,12 @@ if __name__ == "__main__":
|
||||
|
||||
tmp_files.append((cam_w, cam_h, tmp_res.name))
|
||||
for cam_w, cam_h, tmp_name in tmp_files:
|
||||
with open(tmp_name, "rb") as f:
|
||||
compiled_jit, dev_name = load_oob(f)
|
||||
output_data[(cam_w, cam_h)] = compiled_jit
|
||||
output_data['input_devices']['warp'] = dev_name
|
||||
warp_parts, warp_extras = read_parts(tmp_name)
|
||||
output_data[(cam_w, cam_h)] = warp_parts[(cam_w, cam_h)]
|
||||
output_data['input_devices']['warp'] = warp_extras['device']
|
||||
os.remove(tmp_name)
|
||||
output_data['metadata']['warp_dev'] = Device.DEFAULT
|
||||
|
||||
with open(args.output, "wb") as file:
|
||||
dump_oob(output_data, file)
|
||||
|
||||
pkl_size = os.path.getsize(args.output)
|
||||
print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)")
|
||||
chunk_targets = get_chunk_targets(args.output, pkl_size)
|
||||
chunk_file(args.output, chunk_targets)
|
||||
print(f"Chunked into {len(chunk_targets) - 1} file(s)")
|
||||
parts = {key: output_data.pop(key) for key in [key for key, value in output_data.items() if isinstance(value, tuple)]}
|
||||
write_bundle(args.output, parts, output_data)
|
||||
print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(parts)} JITs)")
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
import runpy
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import _patch_ops_serialization
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
|
||||
|
||||
def initialize_check_inputs(arrays: dict):
|
||||
rng = np.random.default_rng(7)
|
||||
for value in arrays.values():
|
||||
value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 2 if value.dtype == np.bool_ else 16, value.shape)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_patch_ops_serialization()
|
||||
upstream_script = os.path.join(os.environ.get("TINYGRAD_PATH", "tinygrad_repo"), "examples/openpilot/compile_onnx.py")
|
||||
runpy.run_path(upstream_script, run_name="__main__")
|
||||
sys.path.insert(0, os.environ.get("TINYGRAD_PATH", "tinygrad_repo"))
|
||||
from examples.openpilot.compile_onnx import onnx_metadata
|
||||
from examples.openpilot.helpers import allocate_inputs, benchmark
|
||||
from tinygrad import Device, Tensor, TinyJit
|
||||
from tinygrad.dtype import _to_np_dtype
|
||||
from tinygrad.nn.onnx import OnnxRunner
|
||||
|
||||
parser = argparse.ArgumentParser(description="Compile a native-architecture ONNX model into a plain tinygrad artifact")
|
||||
parser.add_argument('onnx')
|
||||
parser.add_argument('output')
|
||||
parser.add_argument('--device-input', action='append', default=[])
|
||||
parser.add_argument('--benchmark-runs', type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
runner, (properties, output_shapes) = OnnxRunner(args.onnx), onnx_metadata(args.onnx)
|
||||
device_inputs = runner.graph_inputs.keys() if '*' in args.device_input else args.device_input
|
||||
metadata = {'metadata': properties} | {f'{kind}_shapes': {name: tuple(d if isinstance(d, int) else 0 for d in shape) for name, shape in shapes.items()}
|
||||
for kind, shapes in [('input', {name: spec.shape for name, spec in runner.graph_inputs.items()}),
|
||||
('output', output_shapes)]}
|
||||
input_specs = {name: (tuple(s if isinstance(s, int) else 1 for s in spec.shape), np.dtype(_to_np_dtype(spec.dtype)).str,
|
||||
Device.DEFAULT if name in device_inputs else 'NPY') for name, spec in runner.graph_inputs.items()}
|
||||
|
||||
def model(inputs):
|
||||
return {name: value.contiguous() for name, value in runner({name: value.to(Device.DEFAULT) for name, value in inputs.items()}).items()}
|
||||
output_specs = {name: (value.shape, np.dtype(_to_np_dtype(value.dtype)).name, Device.DEFAULT) for name, value in model(allocate_inputs(input_specs)).items()}
|
||||
|
||||
@TinyJit(prune=True)
|
||||
def jit(output_buffers, **inputs):
|
||||
Tensor.realize(*(output_buffers[name].assign(value) for name, value in model(inputs).items()))
|
||||
|
||||
check_inputs = allocate_inputs(input_specs, initialize_check_inputs) | {'output_buffers': allocate_inputs(output_specs)}
|
||||
with record_kernel_linear() as recorded:
|
||||
for i in range(2 + args.benchmark_runs):
|
||||
benchmark(jit, cb=lambda t, i=i: print(f" [{i + 1}/{2 + args.benchmark_runs}] {t * 1e3:.2f} ms"), **check_inputs)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
|
||||
frame, _ = capture_frame(jit, check_inputs, 'output_buffers')
|
||||
part = serialize_jit(jit, recorded[0], frame)
|
||||
PlainJit(part[0], memoryview(part[1]))
|
||||
write_bundle(args.output, {'run': part}, {'metadata': metadata, 'input_specs': input_specs, 'output_specs': output_specs})
|
||||
print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(part[0]['calls'])} kernels)")
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import argparse
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compile the two-frame yuv420 camera warp into a plain tinygrad artifact")
|
||||
parser.add_argument("--frame", required=True, help="width,height,stride,y_height,uv_height,buffer_size")
|
||||
parser.add_argument("--warp-to", required=True, help="model input WxH")
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
from examples.openpilot.compile_warp import make_frame_prepare, parse_frame, parse_size
|
||||
from tinygrad import Device, Tensor, TinyJit
|
||||
frame = parse_frame(args.frame)
|
||||
warp = make_frame_prepare(frame, *parse_size(args.warp_to))
|
||||
run = TinyJit(lambda input_frame, M_inv: Tensor.stack(*(warp(input_frame[i], M_inv[i]) for i in range(2))), prune=True)
|
||||
rng = np.random.default_rng(42)
|
||||
inputs = {"input_frame": Tensor(rng.integers(0, 256, (2, frame.size), dtype=np.uint8), device=Device.DEFAULT).realize(),
|
||||
"M_inv": Tensor((rng.standard_normal((2, 3, 3)) * 8).astype(np.float32), device=Device.DEFAULT).realize()}
|
||||
with record_kernel_linear() as recorded:
|
||||
run(**inputs)
|
||||
run(**inputs)
|
||||
check, _ = capture_frame(run, inputs)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
part = serialize_jit(run, recorded[0], check)
|
||||
PlainJit(*part)
|
||||
specs = {"input_frame": ((2, frame.size), "|u1", Device.DEFAULT), "M_inv": ((2, 3, 3), "<f4", Device.DEFAULT)}
|
||||
write_bundle(args.output, {"run": part}, {"input_specs": specs, "metadata": {}})
|
||||
print(f"Saved warp to {args.output} ({len(part[0]['calls'])} kernels)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import argparse
|
||||
import dataclasses
|
||||
import itertools
|
||||
import mmap
|
||||
import re
|
||||
import struct
|
||||
import tempfile
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import ALIGNMENT, Blob, PlainJit, flatten, patch_fetch_fw, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame
|
||||
|
||||
|
||||
def _root(u):
|
||||
from tinygrad.uop.ops import Ops
|
||||
while u.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK, Ops.INDEX):
|
||||
u = u.src[0]
|
||||
return u
|
||||
|
||||
|
||||
def _view(u) -> tuple[int, int | None]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
start, size = 0, None
|
||||
while u.op in (Ops.AFTER, Ops.BITCAST, Ops.SHRINK):
|
||||
if u.op is Ops.SHRINK:
|
||||
start, size = start + u.src[1].arg, u.src[2].arg if size is None else size
|
||||
u = u.src[0]
|
||||
return start, size
|
||||
|
||||
|
||||
def _address(v):
|
||||
from tinygrad.uop.ops import Ops
|
||||
add = 0
|
||||
while v.op is not Ops.GETADDR:
|
||||
add += v.src[1].arg if v.op is Ops.ADD else 0
|
||||
v = v.src[0]
|
||||
start, size = _view(v.src[0])
|
||||
return _root(v.src[0]), add + start, size
|
||||
|
||||
|
||||
def _patched(buf, topo) -> tuple[bytearray, dict]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
blob, rows = bytearray(buf.max_numel() * buf.dtype.itemsize), {}
|
||||
for u in topo:
|
||||
if u.op is not Ops.STORE or _root(u.src[0]) is not buf:
|
||||
continue
|
||||
if (value := u.src[1]).op is Ops.BINARY or value.src[0].op is Ops.BINARY:
|
||||
data = (value if value.op is Ops.BINARY else value.src[0]).arg
|
||||
blob[:len(data)] = data
|
||||
else:
|
||||
view, offsets = u.src[0].src
|
||||
rows |= {_view(view)[0] + o.arg * view.dtype.itemsize: w for o, w in zip(offsets.src, value.src, strict=True)}
|
||||
return blob, rows
|
||||
|
||||
|
||||
def _evaluate(u, env):
|
||||
from tinygrad.uop.ops import GroupOp, Ops, exec_alu
|
||||
if u.op in (Ops.AFTER, Ops.CAST, Ops.BITCAST):
|
||||
return _evaluate(u.src[0], env)
|
||||
if u.op is Ops.CONST:
|
||||
return u.arg
|
||||
if u.op in (Ops.PARAM, Ops.RANGE):
|
||||
return env.get(u.arg.slot if u.op is Ops.PARAM else u)
|
||||
if u.op is Ops.LOAD:
|
||||
table, index = _root(u.src[0].src[0]), _evaluate(u.src[0].src[1], env)
|
||||
return struct.unpack_from(u.dtype.fmt, table.arg, index * u.dtype.itemsize)[0] if table.op is Ops.BINARY and index is not None else None
|
||||
if u.op in GroupOp.ALU and None not in (values := [_evaluate(x, env) for x in u.src]):
|
||||
return exec_alu(u.op, u.dtype, values, False)
|
||||
return None
|
||||
|
||||
|
||||
def _input_pokes(submit, kernargs) -> dict[int, tuple[int, int]]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
outer = dict(enumerate(submit.src[1:]))
|
||||
kernargs_params = {i for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]) is kernargs}
|
||||
inputs = {i: _root(a.src[0]).arg.slot for i, a in outer.items() if a.op is Ops.GETADDR and _root(a.src[0]).op is Ops.PARAM}
|
||||
pokes = {}
|
||||
for call in (u for u in submit.src[0].src[0].toposort() if u.op is Ops.CALL and len(u.src) > 3):
|
||||
_, address, value, *_ = call.src[1:]
|
||||
used = list({x.arg.slot for x in value.toposort() if x.op is Ops.PARAM} & inputs.keys())
|
||||
if len(used) != 1 or not kernargs_params & {x.arg.slot for x in address.toposort() if x.op is Ops.PARAM}:
|
||||
continue
|
||||
ranges = list(dict.fromkeys(x for x in (*address.toposort(), *value.toposort()) if x.op is Ops.RANGE))
|
||||
for trip in itertools.product(*[range(int(r.vmax) + 1) for r in ranges]):
|
||||
env = dict.fromkeys(outer, 0) | dict(zip(ranges, trip, strict=True))
|
||||
if (offset := _evaluate(address, env)) % 8 == 0:
|
||||
pokes[offset] = (inputs[used[0]], _evaluate(value, env))
|
||||
return pokes
|
||||
|
||||
|
||||
def _elf(image: bytes, rodata: int) -> bytes:
|
||||
names, offset, data, headers = b"\0.text\0.rodata\0.shstrtab\0", 64, b"", []
|
||||
for name, kind, address, content in [(0, 0, 0, b""), (1, 1, ALIGNMENT, image[:rodata]), (7, 1, ALIGNMENT + rodata, image[rodata:]), (15, 3, 0, names)]:
|
||||
headers.append(struct.pack("<IIQQQQIIQQ", name, kind, 2 if kind == 1 else 0, address, offset, len(content), 0, 0, 1, 0))
|
||||
data, offset = data + content, offset + len(content)
|
||||
return b"\x7fELF\x02\x01\x01" + bytes(9) + struct.pack("<HHIQQQIHHHHHH", 1, 0xe0, 1, 0, 0, offset, 0, 64, 0, 0, 64, 4, 3) + data + b"".join(headers)
|
||||
|
||||
|
||||
def convert(jit, kwargs: dict, frame: dict, target) -> tuple[dict, memoryview]:
|
||||
import tinygrad.runtime.autogen.am.pm4_nv as pm4
|
||||
import tinygrad.runtime.autogen.amd_gpu as amd_gpu
|
||||
from tinygrad.dtype import dtypes
|
||||
from tinygrad.engine.jit import _prepare_jit_inputs
|
||||
from tinygrad.helpers import unwrap
|
||||
from tinygrad.uop.ops import Ops
|
||||
submit = jit.captured._linear.src[0].without_after
|
||||
kernels, topo = submit.arg.aux.kernels, list(jit.captured._linear.toposort(enter_calls=True))
|
||||
allocs = {str(u.tag): u for u in topo if u.op is Ops.ALLOC and u.tag is not None}
|
||||
(commands, patched), kernargs = _patched(allocs["cmdbuf_compute_0"], topo), allocs["kernargs_compute_0"]
|
||||
argument_words, pokes = _patched(kernargs, topo)[1], _input_pokes(submit, kernargs)
|
||||
sh = {name: getattr(amd_gpu, f"reg{name}") + amd_gpu.GC_BASE__INST0_SEG0 - pm4.PACKET3_SET_SH_REG_START
|
||||
for name in ("COMPUTE_PGM_LO", "COMPUTE_USER_DATA_0", "COMPUTE_NUM_THREAD_X")} if hasattr(amd_gpu, "regCOMPUTE_PGM_LO") else \
|
||||
{"COMPUTE_PGM_LO": 0x20c, "COMPUTE_USER_DATA_0": 0x240, "COMPUTE_NUM_THREAD_X": 0x207}
|
||||
|
||||
dispatches, registers, offset = [], {}, 0
|
||||
while offset < len(commands):
|
||||
word = lambda o: patched.get(o, struct.unpack_from("<I", commands, o)[0]) # noqa: E731
|
||||
if not isinstance(header := word(offset), int) or header >> 30 != 3:
|
||||
offset += 4
|
||||
continue
|
||||
count, opcode = ((header >> 16) & 0x3fff) + 1, (header >> 8) & 0xff
|
||||
body = [word(offset + 4 + 4 * i) for i in range(count)]
|
||||
if opcode == pm4.PACKET3_SET_SH_REG:
|
||||
registers |= {body[0] + i: v for i, v in enumerate(body[1:])}
|
||||
elif opcode == pm4.PACKET3_DISPATCH_DIRECT:
|
||||
local = [registers[sh["COMPUTE_NUM_THREAD_X"] + i] for i in range(3)]
|
||||
dispatches.append((registers[sh["COMPUTE_PGM_LO"]], registers[sh["COMPUTE_USER_DATA_0"]], local, body[:3]))
|
||||
offset += 4 + 4 * count
|
||||
assert len(dispatches) == len(kernels), f"decoded {len(dispatches)} dispatches, the JIT has {len(kernels)} kernels"
|
||||
|
||||
sink = tempfile.TemporaryFile(dir=".")
|
||||
blob, buffers, programs = Blob(sink), {}, {}
|
||||
u8 = repr(dtypes.uint8)
|
||||
|
||||
def store(array) -> dict:
|
||||
array = np.ascontiguousarray(array)
|
||||
return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)}
|
||||
|
||||
calls, written, needs_init = [], set(), set()
|
||||
for (program, arguments, local, grid), kernel in zip(dispatches, kernels, strict=True):
|
||||
image_alloc, entry, _ = _address(program)
|
||||
if image_alloc not in programs:
|
||||
image = bytes(_patched(image_alloc, topo)[0])
|
||||
rodata = next(r for r in range(0, len(image) - 24, 64) if r + struct.unpack_from("<q", image, r + 16)[0] == entry)
|
||||
programs[image_alloc] = blob.add(_elf(image, rodata))
|
||||
start, size = _view(arguments.src[0])
|
||||
args = []
|
||||
for slot in range(start, start + unwrap(size), 8):
|
||||
if slot in pokes:
|
||||
assert pokes[slot][1] == 0, "the plain format passes whole inputs, this kernel takes a view into one"
|
||||
args.append({"input": pokes[slot][0]})
|
||||
elif slot in argument_words:
|
||||
base, at, nbytes = _address(argument_words[slot])
|
||||
args.append({"buffer": buffers.setdefault(base, len(buffers)), "offset": at, "nbytes": nbytes or base.arg.buffer.nbytes, "dtype": u8})
|
||||
outs, ins = kernel[6]
|
||||
calls.append({"kind": "program", "name": re.sub(r"\x1b\[[0-9;]*m", "", kernel[1]), "lib": programs[image_alloc], "target": dataclasses.asdict(target),
|
||||
"signature": [[f"data{i}", i, u8, [a.get("nbytes", 1)]] for i, a in enumerate(args)], "global_size": grid, "local_size": local,
|
||||
"globals": list(range(len(args))), "outs": list(outs), "ins": list(ins), "args": args})
|
||||
needs_init |= {args[i]["buffer"] for i in ins if "buffer" in args[i] and args[i]["buffer"] not in written}
|
||||
written |= {args[i]["buffer"] for i in outs if "buffer" in args[i]}
|
||||
|
||||
names = list(jit.captured.expected_names)
|
||||
inputs = [{"slot": slot, "name": names[slot] if slot < len(names) else None, "dtype": repr(u.dtype), "numel": int(u.max_numel()), "device": u.device}
|
||||
for slot, u in enumerate(_prepare_jit_inputs((), kwargs)[0])]
|
||||
header = {"input_names": names, "calls": calls, "returns": {"kind": "NoneType", "tensors": []}, "inputs": inputs,
|
||||
"buffers": [{"nbytes": b.arg.buffer.nbytes, "device": b.arg.buffer.device, "dtype": u8}
|
||||
| ({"init": blob.add(b.arg.buffer.ensure_allocated().as_memoryview())} if i in needs_init else {}) for b, i in buffers.items()],
|
||||
"check": {"inputs": [[path, store(array), device] for path, array, device in frame["inputs"]],
|
||||
"outputs": [store(array) for array in frame["outputs"]], "output_container": frame["output_container"]}}
|
||||
with sink:
|
||||
sink.flush()
|
||||
return header, memoryview(mmap.mmap(sink.fileno(), 0, access=mmap.ACCESS_READ))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert commaai's precompiled chestnut driving model into a plain tinygrad artifact")
|
||||
parser.add_argument("pkl", help="big_driving_tinygrad.pkl")
|
||||
parser.add_argument("output")
|
||||
args = parser.parse_args()
|
||||
|
||||
patch_fetch_fw()
|
||||
from examples.openpilot.helpers import allocate_inputs, load_pickle
|
||||
from tinygrad import Device, Tensor
|
||||
from tinygrad.engine.realize import lower_and_compile
|
||||
artifact = load_pickle(args.pkl, out_of_band=True)
|
||||
artifact["run"].captured._linear = lower_and_compile(artifact["run"].captured._linear)
|
||||
rng = np.random.default_rng(7)
|
||||
|
||||
def initialize(arrays: dict):
|
||||
for value in arrays.values():
|
||||
value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 2 if value.dtype == np.bool_ else 16, value.shape)
|
||||
kwargs = allocate_inputs(artifact["input_specs"], initialize) | {"output_buffers": allocate_inputs(artifact["output_specs"])}
|
||||
frame, _ = capture_frame(artifact["run"], kwargs, "output_buffers")
|
||||
part = convert(artifact["run"], kwargs, frame, Device[Device.DEFAULT].renderer.target)
|
||||
|
||||
plain = PlainJit(*part) # replays the frame, within tolerance
|
||||
replay = {path[0]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 1}
|
||||
replay["output_buffers"] = {path[1]: Tensor(array, device=device).realize() for path, array, device in frame["inputs"] if len(path) == 2}
|
||||
flatten(plain(**replay))
|
||||
for index, (got, expected) in enumerate(zip([t.numpy() for t in replay["output_buffers"].values()], frame["outputs"], strict=True)):
|
||||
assert np.array_equal(got, expected), f"output {index} of the plain artifact differs from the pickle"
|
||||
write_bundle(args.output, {"run": part}, {key: artifact[key] for key in ("metadata", "input_specs", "output_specs")})
|
||||
print(f"converted {len(part[0]['calls'])} kernels, bit-exact with the pickle: {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import bisect
|
||||
import itertools
|
||||
import math
|
||||
from functools import partial
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import _dtype, read_parts
|
||||
|
||||
|
||||
def pack_layout(shapes: dict[str, tuple], frame_size: int) -> tuple[dict[str, int], int]:
|
||||
offsets, offset = {}, 0
|
||||
for name, shape in ({"tfm": (2, 3, 3)} | shapes).items():
|
||||
offsets[name], offset = offset, offset + -(-math.prod(shape) * 4 // 128) * 128
|
||||
return offsets | {"frames": offset}, offset + 2 * frame_size
|
||||
|
||||
|
||||
class _Blobs: # the model's and the warp's blobs as one address space, for a part whose ranges span both files
|
||||
def __init__(self, *blobs):
|
||||
self.blobs, self.starts = blobs, list(itertools.accumulate([0] + [len(blob) for blob in blobs[:-1]]))
|
||||
|
||||
def __getitem__(self, span: slice):
|
||||
index = bisect.bisect_right(self.starts, span.start) - 1
|
||||
return self.blobs[index][span.start - self.starts[index]:span.stop - self.starts[index]]
|
||||
|
||||
|
||||
def fused_with_warp(cam_w: int, cam_h: int, chestnut: bool): # load_model's extend: a native model runs upload, warp and model as one submit
|
||||
return partial(frame_part, f"{BASEDIR}/openpilot/sunnypilot/modeld_v2/models/{'big_' if chestnut else ''}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl")
|
||||
|
||||
|
||||
def frame_part(warp_path: str, parts: dict, extras: dict) -> dict:
|
||||
if "input_specs" not in extras: # a legacy model warps in its own parts
|
||||
return {}
|
||||
(model, model_blob), (warp, warp_blob) = parts["run"], read_parts(warp_path)[0]["run"]
|
||||
states = sorted(name for name in extras["input_specs"] if f"next_{name}" in extras["metadata"]["output_shapes"])
|
||||
host = {name: shape for name, (shape, _, _) in extras["input_specs"].items() if name not in states and name != "new_img"}
|
||||
frame_size = next(s["numel"] for s in warp["inputs"] if s["name"] == "input_frame") // 2
|
||||
offsets, packed = pack_layout(host, frame_size)
|
||||
shift, upload, u8 = len(model_blob), len(model["buffers"]) + len(warp["buffers"]), repr(_dtype("dtypes.uint8"))
|
||||
|
||||
def view(name: str, nbytes: int, dtype: str) -> dict:
|
||||
return {"buffer": upload, "offset": offsets[name], "nbytes": nbytes, "dtype": dtype}
|
||||
outputs = sorted(s["slot"] for s in model["inputs"] if s["name"] is None)
|
||||
names = sorted(["host", *states])
|
||||
slots = {s["slot"]: names.index(s["name"]) for s in model["inputs"] if s["name"] in states} | \
|
||||
{slot: len(names) + i for i, slot in enumerate(outputs)}
|
||||
returned = warp["returns"]["tensors"][0]
|
||||
model_inputs = {s["slot"]: {"input": slots[s["slot"]]} if s["slot"] in slots else
|
||||
{k: v for k, v in returned.items() if k != "shape"} | {"buffer": returned["buffer"] + len(model["buffers"])} if s["name"] == "new_img" else
|
||||
view(s["name"], s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"]) for s in model["inputs"]}
|
||||
warp_inputs = {s["slot"]: view("frames" if s["name"] == "input_frame" else "tfm", s["numel"] * _dtype(s["dtype"]).itemsize, s["dtype"])
|
||||
for s in warp["inputs"]}
|
||||
|
||||
def remap(calls: list, inputs: dict, buffers: int, lib: int) -> list:
|
||||
return [call | ({"lib": [call["lib"][0] + lib, call["lib"][1] + lib]} if "lib" in call else {}) |
|
||||
{"args": [dict(inputs[a["input"]]) if "input" in a else a | {"buffer": a["buffer"] + buffers} for a in call["args"]]} for call in calls]
|
||||
header = {"input_names": names, "returns": {"kind": "NoneType", "tensors": []}, "check": None,
|
||||
"inputs": [{"slot": names.index("host"), "name": "host", "dtype": u8, "numel": packed, "device": "NPY"}] +
|
||||
[s | {"slot": slots[s["slot"]]} for s in model["inputs"] if s["slot"] in slots],
|
||||
"buffers": model["buffers"] + [b | ({"init": [b["init"][0] + shift, b["init"][1] + shift]} if "init" in b else {}) for b in warp["buffers"]] +
|
||||
[{"nbytes": packed, "device": model["inputs"][0]["device"], "dtype": u8}],
|
||||
"calls": [{"kind": "copy", "args": [{"buffer": upload, "offset": 0, "nbytes": packed, "dtype": u8}, {"input": names.index("host")}]}] +
|
||||
remap(warp["calls"], warp_inputs, len(model["buffers"]), shift) + remap(model["calls"], model_inputs, 0, 0)}
|
||||
return {"run": (header, _Blobs(model_blob, warp_blob))} # replaces the model's run: one set of buffers, no separate warp
|
||||
@@ -5,14 +5,25 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
import builtins
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import struct
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import mmap
|
||||
import os
|
||||
import pickle
|
||||
import inspect
|
||||
import importlib
|
||||
import tempfile
|
||||
import shutil
|
||||
import enum
|
||||
import struct
|
||||
|
||||
MAGIC = b"SPTGPLN1"
|
||||
ALIGNMENT = 256
|
||||
METADATA_TYPES = ("slice", "tuple", "list", "dict", "set", "frozenset", "complex")
|
||||
RUNTIME_ENVIRONMENT = ("GMMU", "AMD_AQL", "AMD_DISABLE_SDMA", "GRAPH_ONE_KERNEL")
|
||||
|
||||
|
||||
def _patch_system_flock_acquire():
|
||||
@@ -32,86 +43,289 @@ def _patch_system_flock_acquire():
|
||||
pass
|
||||
_patch_system_flock_acquire()
|
||||
|
||||
def _pad_args(func, args, kwargs):
|
||||
|
||||
def patch_fetch_fw() -> None:
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
except Exception:
|
||||
return args, kwargs
|
||||
params = list(sig.parameters.values())
|
||||
if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'):
|
||||
params = params[1:]
|
||||
|
||||
new_args = list(args)
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
|
||||
if len(new_args) > len(params) and not has_varargs:
|
||||
new_args = new_args[:len(params)]
|
||||
|
||||
for i in range(len(new_args), len(params)):
|
||||
param = params[i]
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
val = param.default if param.default is not inspect.Parameter.empty else None
|
||||
new_args.append(val)
|
||||
return new_args, kwargs
|
||||
import zstandard
|
||||
except ImportError:
|
||||
return
|
||||
zstandard.decompress = lambda data, *_: zstandard.ZstdDecompressor().stream_reader(data).read() # ty: ignore[invalid-assignment]
|
||||
|
||||
|
||||
def _dynamic_factory(real_class):
|
||||
if isinstance(real_class, type) and issubclass(real_class, enum.Enum):
|
||||
return real_class
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
try:
|
||||
return real_class(*args, **kwargs)
|
||||
except TypeError:
|
||||
new_args, new_kwargs = _pad_args(real_class, args, kwargs)
|
||||
return real_class(*new_args, **new_kwargs)
|
||||
|
||||
class DynamicMeta(type(real_class)):
|
||||
def __call__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
class DynamicProxy(real_class, metaclass=DynamicMeta):
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
DynamicProxy.__name__ = real_class.__name__
|
||||
DynamicProxy.__module__ = real_class.__module__
|
||||
return DynamicProxy
|
||||
class ArtifactMismatch(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DynamicTinygradUnpickler(pickle.Unpickler):
|
||||
class Blob:
|
||||
def __init__(self, sink=None):
|
||||
self.chunks: list = []
|
||||
self.locations: dict[bytes, list[int]] = {}
|
||||
self.size, self.write = 0, sink.write if sink else self.chunks.append
|
||||
|
||||
def add(self, data) -> list[int]:
|
||||
if (key := hashlib.blake2b(data, digest_size=16).digest()) not in self.locations:
|
||||
start = self.size + -self.size % ALIGNMENT
|
||||
self.write(bytes(start - self.size)), self.write(data)
|
||||
self.locations[key], self.size = [start, start + memoryview(data).nbytes], start + memoryview(data).nbytes
|
||||
return self.locations[key]
|
||||
|
||||
|
||||
class _MetadataUnpickler(pickle.Unpickler):
|
||||
def find_class(self, module, name):
|
||||
real_class = getattr(importlib.import_module(module), name)
|
||||
if module.startswith("tinygrad"):
|
||||
return _dynamic_factory(real_class)
|
||||
return real_class
|
||||
if module == "builtins" and name in METADATA_TYPES:
|
||||
return getattr(builtins, name)
|
||||
raise pickle.UnpicklingError(f"{module}.{name} is not allowed in model metadata")
|
||||
|
||||
|
||||
def load_oob(f):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
def buffers():
|
||||
while (h := f.read(8)):
|
||||
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
|
||||
f.readinto(pb)
|
||||
yield pb
|
||||
return DynamicTinygradUnpickler(io.BytesIO(opcodes), buffers=buffers()).load()
|
||||
def flatten(value) -> list:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [tensor for item in value for tensor in flatten(item)]
|
||||
return [] if value is None else [value]
|
||||
|
||||
|
||||
def dump_oob(obj, f):
|
||||
with tempfile.TemporaryFile(dir=".") as tmp:
|
||||
def buffer_cb(buffer):
|
||||
data = buffer.raw()
|
||||
tmp.write(struct.pack('<q', len(data)))
|
||||
tmp.write(data)
|
||||
buffer.release()
|
||||
return False
|
||||
def _relocate(value, part_blob: memoryview, blob: Blob):
|
||||
if isinstance(value, dict):
|
||||
return {k: blob.add(part_blob[slice(*v)]) if k in ("lib", "init", "data") else _relocate(v, part_blob, blob) for k, v in value.items()}
|
||||
return [_relocate(v, part_blob, blob) for v in value] if isinstance(value, list) else value
|
||||
|
||||
stream = io.BytesIO()
|
||||
pickle.Pickler(stream, protocol=5, buffer_callback=buffer_cb).dump(obj)
|
||||
opcodes = stream.getvalue()
|
||||
f.write(struct.pack('<q', len(opcodes)))
|
||||
f.write(opcodes)
|
||||
tmp.seek(0)
|
||||
shutil.copyfileobj(tmp, f)
|
||||
|
||||
def write_bundle(path: str, parts: dict, extras: dict | None = None):
|
||||
blob = Blob()
|
||||
headers = {key: _relocate(part_header, memoryview(part_blob), blob) for key, (part_header, part_blob) in parts.items()}
|
||||
extras_location = blob.add(pickle.dumps(extras or {}, protocol=4))
|
||||
entries = [{"key": list(key) if isinstance(key, tuple) else key, "blob": [0, blob.size], "header": header} for key, header in headers.items()]
|
||||
header = json.dumps({"parts": entries, "extras": extras_location}).encode()
|
||||
with open(path, "wb") as file:
|
||||
file.write(MAGIC + struct.pack("<Q", len(header)) + header + bytes(-(len(MAGIC) + 8 + len(header)) % ALIGNMENT))
|
||||
file.writelines(blob.chunks)
|
||||
|
||||
|
||||
def read_parts(path: str) -> tuple[dict, dict]:
|
||||
with open(path, "rb") as file:
|
||||
data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY))
|
||||
if bytes(data[:len(MAGIC)]) != MAGIC:
|
||||
raise ValueError(f"{path} is not a plain tinygrad model, recompile it with compile_modeld.py")
|
||||
start = len(MAGIC) + 8 + struct.unpack_from("<Q", data, len(MAGIC))[0]
|
||||
header, blob = json.loads(bytes(data[len(MAGIC) + 8:start])), data[start + -start % ALIGNMENT:]
|
||||
parts = {tuple(e["key"]) if isinstance(e["key"], list) else e["key"]: (e["header"], blob[slice(*e["blob"])]) for e in header["parts"]}
|
||||
return parts, _MetadataUnpickler(io.BytesIO(bytes(blob[slice(*header["extras"])]))).load()
|
||||
|
||||
|
||||
def _extended(path: str, extend) -> tuple[dict, dict, str]:
|
||||
parts, extras = read_parts(path)
|
||||
added = extend(parts, extras) if extend else {}
|
||||
return parts | added, extras, _parts_digest(added) if added else ""
|
||||
|
||||
|
||||
def load_model(path: str, device_map: dict | None = None, verify: bool = True, cache: bool = True, extend=None) -> dict:
|
||||
parts, extras, extra = _extended(path, extend)
|
||||
if cache and (cached := _read_cache(path, parts, device_map, extra)) is not None:
|
||||
return extras | cached
|
||||
shared: dict = {}
|
||||
jits = {key: PlainJit(header, blob, device_map, verify, shared) for key, (header, blob) in parts.items()}
|
||||
if cache:
|
||||
_write_cache(path, jits, device_map, extra)
|
||||
return extras | jits
|
||||
|
||||
|
||||
@functools.cache
|
||||
def tinygrad_identity() -> str:
|
||||
import tinygrad
|
||||
root, digest = os.path.dirname(tinygrad.__file__), hashlib.sha256()
|
||||
for directory, _, files in sorted(os.walk(root)):
|
||||
for name in sorted(file for file in files if file.endswith(".py")):
|
||||
with open(path := os.path.join(directory, name), "rb") as file:
|
||||
digest.update(f"{os.path.relpath(path, root)}:".encode() + file.read())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _parts_digest(parts: dict) -> str:
|
||||
digest = hashlib.sha256()
|
||||
for _, (header, blob) in sorted(parts.items(), key=lambda item: repr(item[0])):
|
||||
digest.update(json.dumps(header, sort_keys=True).encode())
|
||||
for call in header["calls"]:
|
||||
if call["kind"] == "program":
|
||||
digest.update(blob[slice(*call["lib"])])
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _cache_key(path: str, device_map: dict | None, extra: str = "") -> bytes:
|
||||
stat = os.stat(path)
|
||||
return json.dumps({"format": MAGIC.decode(), "tinygrad": tinygrad_identity(), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns,
|
||||
"extra": extra, "device_map": sorted((device_map or {}).items()),
|
||||
"env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode()
|
||||
|
||||
|
||||
def _cache_header_matches(file, path: str, device_map: dict | None, extra: str = "") -> bool:
|
||||
return file.read(struct.unpack("<Q", file.read(8))[0]) == _cache_key(path, device_map, extra)
|
||||
|
||||
|
||||
def cache_is_fresh(path: str, device_map: dict | None = None) -> bool: # entry point for warmup proc
|
||||
try:
|
||||
with open(f"{path}.cache", "rb") as file:
|
||||
return _cache_header_matches(file, path, device_map)
|
||||
except (OSError, struct.error):
|
||||
return False
|
||||
|
||||
|
||||
def _read_cache(path: str, parts: dict, device_map: dict | None, extra: str = "") -> dict | None:
|
||||
try:
|
||||
with open(f"{path}.cache", "rb") as file:
|
||||
if not _cache_header_matches(file, path, device_map, extra):
|
||||
return None
|
||||
shared: dict = {}
|
||||
buffers = {repr(key): _allocate_buffers(header, blob, device_map, shared) for key, (header, blob) in parts.items()}
|
||||
return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers[pid[0]][pid[1]]})(file).load()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(path: str, jits: dict, device_map: dict | None, extra: str = "") -> None:
|
||||
from tinygrad.device import Buffer
|
||||
owners = {id(buffer): (repr(key), index) for key, jit in jits.items() for index, buffer in enumerate(jit.buffers)}
|
||||
|
||||
class _Pickler(pickle.Pickler):
|
||||
def persistent_id(self, obj):
|
||||
return owners.get(id(obj)) if isinstance(obj, Buffer) else None
|
||||
|
||||
key, tmp_path = _cache_key(path, device_map, extra), f"{path}.cache.{os.getpid()}.tmp"
|
||||
with contextlib.suppress(Exception):
|
||||
with open(tmp_path, "wb") as file:
|
||||
file.write(struct.pack("<Q", len(key)) + key)
|
||||
_Pickler(file, protocol=5).dump({name: _Jit(jit.captured, jit.input_names, jit.buffers) for name, jit in jits.items()})
|
||||
os.replace(tmp_path, f"{path}.cache")
|
||||
|
||||
|
||||
# Everything below this comment block calls tinygrad internals, so after a tinygrad bump this
|
||||
# is the only code to update, and models are not recompiled. How to do this when discountchubbs is not available:
|
||||
# 1. run modeld_v2/tests/test_plain_artifact.py on the new tinygrad and fix each failing call against the new API: Buffer,
|
||||
# UOp.from_buffer/param (ParamArg), ProgramInfo, Target, KernelInfo, compile_linear, CapturedJit and
|
||||
# _prepare_jit_inputs in tinygrad/engine/jit.py
|
||||
# 2. load a real model with verify=True: ArtifactMismatch means the rebuilt graph still differs from the one that was compiled
|
||||
# The boot cache is keyed on the tinygrad source, so it rebuilds itself. Only a change in how the device runs kernel binaries needs a recompile.
|
||||
# Honestly, I would just have your clanker try it because you likely have zero idea what you are looking at lol..
|
||||
|
||||
def _dtype(text: str):
|
||||
from tinygrad.dtype import dtypes
|
||||
return eval(text, {"__builtins__": {}}, {"dtypes": dtypes})
|
||||
|
||||
|
||||
def _device(recorded: str, device_map: dict | None) -> str:
|
||||
from tinygrad import Device
|
||||
device_map = device_map or {}
|
||||
return Device.canonicalize(device_map.get(recorded, device_map.get(recorded.split(":", 1)[0], recorded)))
|
||||
|
||||
|
||||
def _upload(buffer, data: memoryview, chunk: int = 16 << 20) -> None:
|
||||
from tinygrad.device import Buffer
|
||||
source, chunk = Buffer("NPY", len(data), opaque=memoryview(bytearray(data)) if data.readonly else data), min(chunk, len(data))
|
||||
for offset in sorted({*range(0, len(data) - chunk, chunk), len(data) - chunk}):
|
||||
buffer.view(chunk, offset).ensure_allocated().copy_from(source.view(chunk, offset).ensure_allocated())
|
||||
|
||||
|
||||
def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, shared: dict) -> list:
|
||||
import numpy as np
|
||||
from tinygrad.device import Buffer
|
||||
written = {call["args"][i]["buffer"] for call in header["calls"] for i in (call["outs"] if call["kind"] == "program" else (0,))
|
||||
if "buffer" in call["args"][i]}
|
||||
entries = [(_device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None, entry) for entry in header["buffers"]]
|
||||
keys = [(device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object()
|
||||
for index, (device, data, entry) in enumerate(entries)]
|
||||
for device in {key[0] for key in keys if isinstance(key, tuple) and key not in shared}:
|
||||
if shared.setdefault(("mapped", device), Buffer(device, 1).ensure_allocated().get_storage().host is not None):
|
||||
continue
|
||||
spans = [(entry["init"], entry["nbytes"], key) for (_, _, entry), key in zip(entries, keys, strict=True)
|
||||
if isinstance(key, tuple) and key[0] == device and key not in shared]
|
||||
files = getattr(blob, "starts", [0]) # a part read from several files (frame_fusion's) is uploaded a block per file
|
||||
for _, group in itertools.groupby(sorted(spans, key=lambda span: span[0][0]), key=lambda span: bisect.bisect_right(files, span[0][0])):
|
||||
group = list(group)
|
||||
start, end = min(span[0] for span, _, _ in group), max(span[1] for span, _, _ in group)
|
||||
_upload(block := Buffer(device, end - start).ensure_allocated(), blob[start:end])
|
||||
shared |= {key: block.view(nbytes, span[0] - start).ensure_allocated() for span, nbytes, key in group}
|
||||
buffers = []
|
||||
for (device, data, entry), key in zip(entries, keys, strict=True):
|
||||
if key not in shared:
|
||||
shared[key] = Buffer(device, entry["nbytes"], initial_value=pickle.PickleBuffer(data) if data else None).ensure_allocated()
|
||||
buffers.append(shared[key])
|
||||
return buffers
|
||||
|
||||
|
||||
def _program_uop(call: dict, lib: bytes):
|
||||
from tinygrad.helpers import Target
|
||||
from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp
|
||||
parameters = []
|
||||
for name, compact_slot, dtype, shape in call["signature"]:
|
||||
parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape))
|
||||
image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None
|
||||
parameters.append(parameter.replace(arg=dataclasses.replace(parameter.arg, name=name, image=image)))
|
||||
info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]),
|
||||
outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=Target(**call["target"]))
|
||||
return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)),
|
||||
UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info)
|
||||
|
||||
|
||||
class _Jit:
|
||||
def __init__(self, captured, input_names: list, buffers: list):
|
||||
self.captured, self.input_names, self.buffers = captured, input_names, buffers
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
import tinygrad.engine.jit as jit
|
||||
input_uops, var_vals, names, _ = jit._prepare_jit_inputs(args, kwargs)
|
||||
if names != self.input_names:
|
||||
raise ValueError(f"expected inputs {self.input_names}, got {names}")
|
||||
return self.captured(input_uops, var_vals)
|
||||
|
||||
|
||||
class PlainJit(_Jit):
|
||||
def __init__(self, header: dict, blob: memoryview, device_map: dict | None = None, verify: bool = True, shared: dict | None = None):
|
||||
import numpy as np
|
||||
import tinygrad.engine.jit as jit
|
||||
from tinygrad import Tensor
|
||||
from tinygrad.device import Buffer
|
||||
from tinygrad.dtype import dtypes
|
||||
from tinygrad.uop.ops import Ops, UOp
|
||||
|
||||
inputs = {spec["slot"]: spec for spec in header["inputs"]}
|
||||
buffers = _allocate_buffers(header, blob, device_map, {} if shared is None else shared)
|
||||
bases = [UOp.from_buffer(buffer, _dtype(entry["dtype"])) for buffer, entry in zip(buffers, header["buffers"], strict=True)]
|
||||
|
||||
def argument(described: dict):
|
||||
if "input" in described:
|
||||
spec = inputs[described["input"]]
|
||||
return UOp.param(described["input"], _dtype(spec["dtype"]), spec["numel"], _device(spec["device"], device_map))
|
||||
base, dtype, offset, nbytes = bases[described["buffer"]], _dtype(described["dtype"]), described["offset"], described["nbytes"]
|
||||
if offset == 0 and nbytes == header["buffers"][described["buffer"]]["nbytes"] and dtype == base.dtype:
|
||||
return base
|
||||
return (base if base.dtype.itemsize == 1 else base.bitcast(dtypes.uint8)).shrink(((offset, offset + nbytes),)).bitcast(dtype)
|
||||
|
||||
calls = []
|
||||
for call in header["calls"]:
|
||||
arguments = [argument(described) for described in call["args"]]
|
||||
if call["kind"] == "program":
|
||||
calls.append(_program_uop(call, bytes(blob[slice(*call["lib"])])).call(*arguments))
|
||||
else:
|
||||
calls.append(arguments[0].store_call(arguments[1]))
|
||||
# stand-ins for JIT inputs: copy staging to GPU is decided from the input buffers, the real ones bind per call
|
||||
stand_ins = {slot: UOp.from_buffer(Buffer(_device(spec["device"], device_map), spec["numel"] * _dtype(spec["dtype"]).itemsize).ensure_allocated(),
|
||||
_dtype(spec["dtype"])) for slot, spec in inputs.items()}
|
||||
input_uops = tuple(stand_ins.get(slot, next(iter(stand_ins.values()), None)) for slot in range(max(stand_ins, default=-1) + 1))
|
||||
linear = jit.compile_linear(UOp(Ops.LINEAR, src=tuple(calls)), beam=0, input_uops=input_uops, cache=False)
|
||||
|
||||
returned = [Tensor(argument(t)).reshape(tuple(t["shape"])) for t in header["returns"]["tensors"]]
|
||||
ret = {"NoneType": None, "Tensor": returned[0] if returned else None, "tuple": tuple(returned), "list": returned}[header["returns"]["kind"]]
|
||||
super().__init__(jit.CapturedJit(ret, linear, header["input_names"], []), header["input_names"], buffers)
|
||||
|
||||
if not verify or not (check := header.get("check")):
|
||||
return
|
||||
def array(stored):
|
||||
return np.frombuffer(blob[slice(*stored["data"])], dtype=np.dtype(stored["dtype"])).reshape(stored["shape"]).copy()
|
||||
kwargs: dict = {}
|
||||
for path, stored, device in check["inputs"]:
|
||||
tensor = Tensor(array(stored), device=_device(device, device_map)).realize()
|
||||
kwargs[path[0]] = tensor if len(path) == 1 else {**kwargs.get(path[0], {}), path[1]: tensor}
|
||||
result = self(**kwargs)
|
||||
outputs = kwargs[check["output_container"]].values() if check["output_container"] else flatten(result)
|
||||
for index, (got, stored) in enumerate(zip([t.numpy() for t in outputs], check["outputs"], strict=True)):
|
||||
expected = array(stored)
|
||||
if got.shape != expected.shape or not np.allclose(got, expected, rtol=1e-3, atol=1e-3, equal_nan=True):
|
||||
raise ArtifactMismatch(f"output {index} differs from the compile-time reference frame; recompile the model")
|
||||
|
||||
@@ -11,20 +11,21 @@ import pickle
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.sunnypilot.modeld_v2.frame_fusion import pack_layout
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_model
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import (POLICY_INPUTS, derive_frame_skip,
|
||||
make_split_input_queues, make_supercombo_input_queues)
|
||||
from openpilot.sunnypilot.modeld_v2.stock_dependencies import make_input_queues as stock_make_input_queues, nv12_copy_size
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
from tinygrad.device import Buffer
|
||||
from tinygrad.dtype import DType, dtypes
|
||||
from tinygrad.helpers import round_up
|
||||
from tinygrad.dtype import DType
|
||||
from tinygrad.tensor import Tensor
|
||||
from tinygrad.uop.ops import UOp
|
||||
|
||||
|
||||
def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor:
|
||||
view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view)).reshape(shape)
|
||||
view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view, dtype)).reshape(shape)
|
||||
|
||||
|
||||
class BaseModelAdapter:
|
||||
@@ -64,16 +65,10 @@ class BaseModelAdapter:
|
||||
self.warp_frame_size = self.nv12_info[3]
|
||||
return self.jits[(self.cam_w, self.cam_h)]
|
||||
|
||||
warp_dir = Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models"
|
||||
warp_name = f'{"big_" if self.chestnut else ""}driving_warp_{self.cam_w}x{self.cam_h}_tinygrad.pkl'
|
||||
with open(warp_dir / warp_name, 'rb') as f:
|
||||
warp_data = pickle.load(f)
|
||||
run_warp = warp_data['run']
|
||||
if 'input_specs' in warp_data and 'input_frame' in warp_data['input_specs']:
|
||||
self.warp_frame_size = warp_data['input_specs']['input_frame'][0][1]
|
||||
else:
|
||||
self.warp_frame_size = self.nv12_info[3] if self.chestnut else self.frame_copy_size
|
||||
return run_warp
|
||||
warp = load_model(str(Path(BASEDIR) / "openpilot/sunnypilot/modeld_v2/models" / warp_name), cache=False)
|
||||
self.warp_frame_size = warp['input_specs']['input_frame'][0][1]
|
||||
return warp['run']
|
||||
|
||||
|
||||
class LegacyModelAdapter(BaseModelAdapter):
|
||||
@@ -172,27 +167,19 @@ class NativeTinygradAdapter(BaseModelAdapter):
|
||||
self._vision_input_names = [k for k in self.input_shapes_orig if 'img' in k]
|
||||
self.vision_output_slices = pickle.loads(codecs.decode(self.jits['metadata']['metadata']['output_slices'].encode(), 'base64'))
|
||||
|
||||
self.run_warp = self._load_warp()
|
||||
self.warp_frame_size = self.frame_copy_size
|
||||
self._init_common()
|
||||
self.run_model = self.jits['run']
|
||||
|
||||
warp_frame_size = getattr(self, 'warp_frame_size', self.nv12_info[3])
|
||||
self.input_queues = {name: Tensor(np.zeros(shape, dtype=dtype), device=self.model_device).realize()
|
||||
for name, (shape, dtype) in self.input_shapes.items() if name in self.state_pairs}
|
||||
shapes = {'tfm': (2, 3, 3)} | {name: shape for name, (shape, _) in self.input_shapes.items()
|
||||
if name not in self.state_pairs and name != 'new_img'}
|
||||
npy_size = sum(round_up(math.prod(shape) * 4, 128) for shape in shapes.values())
|
||||
self.packed_input = np.zeros(npy_size + 2 * warp_frame_size, dtype=np.uint8)
|
||||
self.input_host = Tensor(self.packed_input, device='NPY')._buffer()
|
||||
self.input_device = Tensor(self.packed_input, device=self.model_device)._buffer()
|
||||
self.numpy_inputs = {}
|
||||
offset = 0
|
||||
for name, shape in shapes.items():
|
||||
self.numpy_inputs[name] = np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offset)
|
||||
self.input_queues[name] = input_view(self.input_device, shape, dtypes.float32, offset)
|
||||
offset += round_up(self.numpy_inputs[name].nbytes, 128)
|
||||
self.frames = self.packed_input[npy_size:].reshape(2, warp_frame_size)
|
||||
self.warp_inputs = {'input_frame': input_view(self.input_device, self.frames.shape, dtypes.uint8, npy_size), 'M_inv': self.input_queues.pop('tfm')}
|
||||
shapes = {name: shape for name, (shape, _) in self.input_shapes.items() if name not in self.state_pairs and name != 'new_img'}
|
||||
offsets, packed = pack_layout(shapes, self.warp_frame_size)
|
||||
self.packed_input = np.zeros(packed, dtype=np.uint8)
|
||||
self.host = Tensor(self.packed_input, device='NPY')
|
||||
self.numpy_inputs = {name: np.ndarray(shape, dtype=np.float32, buffer=self.packed_input, offset=offsets[name])
|
||||
for name, shape in ({'tfm': (2, 3, 3)} | shapes).items()}
|
||||
self.frames = self.packed_input[offsets['frames']:].reshape(2, self.warp_frame_size)
|
||||
|
||||
self.outputs = {name: Tensor(np.zeros(shape, dtype=dtype), device=device).realize() for name, (shape, dtype, device) in self.jits['output_specs'].items()}
|
||||
for name, next_name in self.state_pairs.items():
|
||||
@@ -211,9 +198,7 @@ class NativeTinygradAdapter(BaseModelAdapter):
|
||||
self.input_queues[key].assign(0).realize()
|
||||
|
||||
def run(self):
|
||||
self.input_device.copy_from(self.input_host)
|
||||
self.input_queues['new_img'] = self.run_warp(**self.warp_inputs)
|
||||
self.run_model(output_buffers=self.outputs, **self.input_queues)
|
||||
self.run_model(host=self.host, **self.input_queues, output_buffers=self.outputs)
|
||||
return self.outputs['outputs']
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ from openpilot.cereal.messaging import PubMaster, SubMaster
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from msgq.visionipc import VisionIpcClient, VisionBuf
|
||||
from opendbc.car.car_helpers import get_demo_car_params
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
@@ -42,7 +41,8 @@ from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_model
|
||||
from openpilot.sunnypilot.modeld_v2.frame_fusion import fused_with_warp
|
||||
from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
@@ -52,8 +52,7 @@ BIG_MODEL_TIMEOUT = 60
|
||||
|
||||
|
||||
def _pkl_exists(path):
|
||||
from openpilot.common.file_chunker import get_manifest_path
|
||||
return os.path.exists(path) or os.path.exists(get_manifest_path(path))
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def _find_driving_pkl(bundle):
|
||||
@@ -108,7 +107,7 @@ class ModelState(ModelStateBase):
|
||||
|
||||
def _init_combined(self, pkl_path, cam_w, cam_h, bundle):
|
||||
cloudlog.warning(f"loading combined pkl: {pkl_path}")
|
||||
jits = load_oob(open_file_chunked(pkl_path))
|
||||
jits = load_model(pkl_path, extend=fused_with_warp(cam_w, cam_h, self.chestnut))
|
||||
|
||||
metadata = jits['metadata']
|
||||
self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU'
|
||||
@@ -258,7 +257,8 @@ def main(demo=False):
|
||||
|
||||
CHESTNUT = chestnut_present()
|
||||
if CHESTNUT:
|
||||
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
|
||||
from tinygrad.runtime.ops_amd import AMDDevice
|
||||
AMDDevice.wait_timeout_ms = 3000
|
||||
|
||||
params = Params()
|
||||
params.put_bool("ChestnutLoading", CHESTNUT)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ebac006f5e35ddb9c7da8ab38f9af87d6d0a87a3299b07cd118f88581f0f561f
|
||||
size 872573
|
||||
oid sha256:f1689356ff8e516f87e3d75f6b53e66953a11ee5a97e859b4808c9a16bfe671b
|
||||
size 3677806
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8eab79e53ebe1bbc3c633acc2a6bee80612cfe7fb5246afff6e0b7259c3a0d3
|
||||
size 872317
|
||||
oid sha256:56cc9690e0e9fb719323d2c6646c1305e8bc6bbdfdc807724a605a0d0a37554b
|
||||
size 7904366
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c48c9150afd01bd7071641415a248308c9a7b3cb47e7095862ef66c9f0429e43
|
||||
size 838183
|
||||
oid sha256:4b22fd6bcb3a3e8a642e6866449dd9c30c12e694ff0741cad9e34e4b00d8c5fa
|
||||
size 3698799
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:59564a7d214fe13dbb30cdd068437c4bf35ec071789c922288046636f711cdfa
|
||||
size 837991
|
||||
oid sha256:9a17fd6acece4fab23784ef0f3f8bba28a049e9599e5e9e5ee7b36e4b1d24a6f
|
||||
size 7924847
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import dataclasses
|
||||
import mmap
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import Blob, flatten
|
||||
|
||||
# Compile time only/ see discountchubbs 4 assistance: turns a captured TinyJit into plain data that helpers.load_model rebuilds. It reads internals of the
|
||||
# tinygrad that compiles the model, so a tinygrad bump that breaks it blocks new builds, never loading already built models.
|
||||
@contextmanager
|
||||
def record_kernel_linear():
|
||||
import tinygrad.engine.jit as jit
|
||||
import tinygrad.engine.realize as realize
|
||||
recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear
|
||||
|
||||
def recording_compile_linear(*args, **kwargs):
|
||||
with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (recorded.append(linear), hcq_compile(linear, *a, **k))[1]):
|
||||
return compile_linear(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(jit, "compile_linear", recording_compile_linear):
|
||||
yield recorded
|
||||
|
||||
|
||||
def capture_frame(jit, kwargs: dict, output_container: str | None = None):
|
||||
inputs = [([name] if key is None else [name, key], tensor.numpy().copy(), tensor.device)
|
||||
for name, value in kwargs.items() for key, tensor in (value.items() if isinstance(value, dict) else [(None, value)])]
|
||||
result = jit(**kwargs)
|
||||
outputs = kwargs[output_container].values() if output_container else flatten(result)
|
||||
return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result
|
||||
|
||||
|
||||
def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
sink = tempfile.TemporaryFile(dir=".")
|
||||
blob, bases, input_specs = Blob(sink), {}, {}
|
||||
|
||||
def store(array) -> dict:
|
||||
array = np.ascontiguousarray(array)
|
||||
return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)}
|
||||
|
||||
def describe(uop) -> dict:
|
||||
if uop.op is Ops.PARAM:
|
||||
input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device})
|
||||
return {"input": uop.arg.slot}
|
||||
buffer = uop.buffer
|
||||
root = buffer._base if buffer._base is not None else buffer
|
||||
base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype))
|
||||
return {"buffer": base[0], "offset": buffer.offset if buffer._base is not None else 0, "nbytes": buffer.nbytes, "dtype": repr(uop.dtype)}
|
||||
|
||||
calls, written, needs_init = [], set(), set()
|
||||
for call in kernel_linear.src:
|
||||
ast, arguments = call.src[0], [describe(argument) for argument in call.src[1:]]
|
||||
if ast.op is Ops.PROGRAM:
|
||||
elf, info = ast.to_elf(), ast.arg
|
||||
global_size, local_size = info.launch_dims({})
|
||||
calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target),
|
||||
"signature": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature],
|
||||
"global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size],
|
||||
"globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments})
|
||||
outs, ins = info.outs, info.ins
|
||||
elif ast.op is Ops.STORE: # a copy: STORE(PARAM dst, PARAM src)
|
||||
calls.append({"kind": "copy", "args": arguments})
|
||||
outs, ins = (0,), (1,)
|
||||
else:
|
||||
raise ValueError(f"unsupported call {ast.op} {ast.arg}")
|
||||
needs_init |= {arguments[i]["buffer"] for i in ins if "buffer" in arguments[i] and arguments[i]["buffer"] not in written}
|
||||
written |= {arguments[i]["buffer"] for i in outs if "buffer" in arguments[i]}
|
||||
|
||||
ret = jit.captured.ret
|
||||
returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]}
|
||||
buffers = [{"nbytes": base.nbytes, "device": base.device, "dtype": repr(dtype)}
|
||||
| ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()]
|
||||
names = list(jit.captured.expected_names)
|
||||
if check is not None:
|
||||
check = {"inputs": [[path, store(array), device] for path, array, device in check["inputs"]], "outputs": [store(array) for array in check["outputs"]],
|
||||
"output_container": check["output_container"]}
|
||||
header = {"input_names": names, "buffers": buffers, "calls": calls, "returns": returns, "check": check,
|
||||
"inputs": [{"slot": slot, "name": names[slot] if slot < len(names) else None} | input_specs[slot] for slot in sorted(input_specs)]}
|
||||
with sink:
|
||||
sink.flush()
|
||||
return header, memoryview(mmap.mmap(sink.fileno(), 0, access=mmap.ACCESS_READ) if blob.size else b"")
|
||||
@@ -188,10 +188,8 @@ def make_pkl_data(archetype):
|
||||
|
||||
|
||||
def write_pkl(tmp_path, archetype):
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
pkl_path = tmp_path / 'driving_test_tinygrad.pkl'
|
||||
with open(pkl_path, 'wb') as f:
|
||||
dump_oob(make_pkl_data(archetype), f)
|
||||
pkl_path.write_bytes(pickle.dumps(make_pkl_data(archetype)))
|
||||
return pkl_path
|
||||
|
||||
|
||||
@@ -211,6 +209,7 @@ def patch_modeld(monkeypatch):
|
||||
def _patch(bundle):
|
||||
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle)
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle)
|
||||
monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(pathlib.Path(path).read_bytes()))
|
||||
|
||||
return _patch
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ class TestStockEquivalence(OpenpilotTestCase):
|
||||
|
||||
def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld):
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
shapes = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256), 'features_buffer': (1, 24, 32, 512),
|
||||
'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2), 'action_t': (1, 2)}
|
||||
import codecs
|
||||
@@ -102,8 +101,7 @@ class TestStockEquivalence(OpenpilotTestCase):
|
||||
(CAM_W, CAM_H): tests_helpers._noop_jit,
|
||||
'run_policy': tests_helpers._noop_jit,
|
||||
}
|
||||
with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f:
|
||||
dump_oob(pkl_data, f)
|
||||
(tmp_path / 'driving_test_tinygrad.pkl').write_bytes(pickle.dumps(pkl_data))
|
||||
bundle = DummyBundle(models=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')])
|
||||
patch_modeld(bundle)
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
|
||||
@@ -5,16 +5,12 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from openpilot.common.parameterized import parameterized
|
||||
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key, read_file_chunked_to_disk
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
|
||||
@@ -168,36 +164,6 @@ class TestOutputSlicePreservation(OpenpilotTestCase):
|
||||
"vision and policy slices should not overlap in keys"
|
||||
|
||||
|
||||
class TestReadFileChunkedToDisk(OpenpilotTestCase):
|
||||
def test_none_passthrough(self):
|
||||
assert read_file_chunked_to_disk(None) is None
|
||||
|
||||
def test_unchunked_source_staged_on_disk(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = Path(d) / "driving_supercombo.onnx"
|
||||
payload = os.urandom(1024)
|
||||
src.write_bytes(payload)
|
||||
|
||||
out = Path(read_file_chunked_to_disk(str(src)))
|
||||
|
||||
assert out.parent == Path(d)
|
||||
assert out.name == "driving_supercombo.onnx.unchunked"
|
||||
assert out.read_bytes() == payload
|
||||
|
||||
def test_chunked_source_reassembled_on_disk(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = Path(d) / "driving_supercombo.onnx"
|
||||
payload = os.urandom(4096)
|
||||
src.write_bytes(payload)
|
||||
chunk_file(str(src), get_chunk_targets(str(src), len(payload)))
|
||||
assert not src.exists()
|
||||
|
||||
out = Path(read_file_chunked_to_disk(str(src)))
|
||||
|
||||
assert out.parent == Path(d)
|
||||
assert out.read_bytes() == payload
|
||||
|
||||
|
||||
class Test4DFeaturesBuffer(OpenpilotTestCase):
|
||||
def test_get_policy_npy_shapes_4d(self):
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes
|
||||
|
||||
@@ -5,13 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import io
|
||||
import pickle
|
||||
import requests
|
||||
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld_module
|
||||
from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers
|
||||
from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H
|
||||
@@ -43,14 +41,11 @@ class TestFallback(OpenpilotTestCase):
|
||||
small_json = requests.get(ModelFetcher.MODEL_URL).json()
|
||||
small_bundle = ModelParser.parse_models(small_json)[-1]
|
||||
|
||||
buf = io.BytesIO()
|
||||
dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf)
|
||||
oob_bytes = buf.getvalue()
|
||||
oob_bytes = pickle.dumps(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']))
|
||||
monkeypatch.setattr(modeld_module, 'load_model', lambda path, **_: pickle.loads(open(path, 'rb').read()))
|
||||
|
||||
for bundle in (big_bundle, small_bundle):
|
||||
artifact = bundle.models[0].artifact
|
||||
for i in range(len(artifact.chunks)):
|
||||
(tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"")
|
||||
(tmp_path / bundle.models[0].artifact.fileName).write_bytes(oob_bytes)
|
||||
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle)
|
||||
assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False
|
||||
|
||||
@@ -7,71 +7,14 @@ See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from tinygrad.device import Device
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model
|
||||
|
||||
|
||||
class TestLegacyModels(unittest.TestCase):
|
||||
def test_legacy_model_load(self):
|
||||
base_name = os.environ.get("MODEL_BASE_NAME")
|
||||
if not base_name:
|
||||
raise unittest.SkipTest("MODEL_BASE_NAME env var not set, skipping integration test.")
|
||||
chunk_dir = os.environ.get("MODEL_CHUNK_DIR", "/tmp/model_chunks")
|
||||
base_path = os.path.join(chunk_dir, base_name)
|
||||
|
||||
try:
|
||||
f = open_file_chunked(base_path)
|
||||
except Exception as error:
|
||||
self.fail(f"Failed to open chunked file {base_path}: {error}")
|
||||
self.addCleanup(f.close)
|
||||
|
||||
real_getitem = Device.__class__.__getitem__
|
||||
|
||||
def safe_getitem(device_self, ix):
|
||||
if ix == "QCOM" and not os.path.exists("/dev/kgsl-3d0"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
if ix == "AMD" and not os.path.exists("/dev/kfd"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
return real_getitem(device_self, ix)
|
||||
|
||||
with patch.object(Device.__class__, "__getitem__", safe_getitem):
|
||||
obj = load_oob(f)
|
||||
|
||||
assert isinstance(obj, dict), "Parsed object is not a dictionary"
|
||||
assert "metadata" in obj, "Metadata key is missing"
|
||||
|
||||
def traverse_jits(d):
|
||||
if hasattr(d, "captured") and d.captured is not None:
|
||||
linear_attr = getattr(d.captured, "_linear", None)
|
||||
if linear_attr is None:
|
||||
try:
|
||||
linear_attr = getattr(d.captured, "linear", None)
|
||||
except AssertionError as e:
|
||||
if "alloc size must be positive" in str(e):
|
||||
return
|
||||
raise
|
||||
|
||||
if linear_attr is not None:
|
||||
stack = [linear_attr]
|
||||
visited = set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if id(node) in visited:
|
||||
continue
|
||||
visited.add(id(node))
|
||||
if hasattr(node, "src") and node.src is not None:
|
||||
for child in node.src:
|
||||
stack.append(child)
|
||||
elif isinstance(d, dict):
|
||||
for v in d.values():
|
||||
traverse_jits(v)
|
||||
elif isinstance(d, (list, tuple)):
|
||||
for v in d:
|
||||
traverse_jits(v)
|
||||
|
||||
if "run_model" in obj:
|
||||
traverse_jits(obj["run_model"])
|
||||
if "run_policy" in obj:
|
||||
traverse_jits(obj["run_policy"])
|
||||
class TestRegistryModels(unittest.TestCase):
|
||||
def test_model_loads(self):
|
||||
if not (base_name := os.environ.get("MODEL_BASE_NAME")):
|
||||
self.skipTest("MODEL_BASE_NAME env var not set, skipping integration test.")
|
||||
bundle = load_model(os.path.join(os.environ.get("MODEL_DIR", "/tmp/models"), base_name), {"QCOM": "CPU", "AMD": "CPU"}, verify=False, cache=False)
|
||||
jits = [value for value in bundle.values() if isinstance(value, PlainJit)]
|
||||
assert "metadata" in bundle and jits and all(len(jit.captured.linear.src) > 0 for jit in jits)
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ['GMMU'] = '0'
|
||||
import multiprocessing
|
||||
import time
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model, read_parts
|
||||
from openpilot.sunnypilot.models.helpers import get_selected_bundle
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
The main entry point for offroad cache creation.
|
||||
Runs every 10 seconds and checks for a stale qcom model cache.
|
||||
"""
|
||||
params, failed, native, spawn = Params(), set(), set(), multiprocessing.get_context("spawn")
|
||||
while True:
|
||||
bundle = get_selected_bundle(params, "qcom")
|
||||
path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else ""
|
||||
if os.path.isfile(path) and not cache_is_fresh(path) and (attempt := (path, os.stat(path).st_mtime_ns)) not in failed | native:
|
||||
if "input_specs" in read_parts(path)[1]: # native models are fused
|
||||
native.add(attempt)
|
||||
else:
|
||||
(worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start()
|
||||
worker.join()
|
||||
if not cache_is_fresh(path):
|
||||
cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}")
|
||||
failed.add(attempt)
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,12 +6,10 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import requests
|
||||
from requests.exceptions import (SSLError, RequestException, HTTPError)
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible
|
||||
from openpilot.cereal import custom
|
||||
|
||||
@@ -26,35 +24,11 @@ class ModelParser:
|
||||
download_uri.sha256 = download_uri_data.get("sha256")
|
||||
return download_uri
|
||||
|
||||
@staticmethod
|
||||
def _parse_chunk(chunk_data) -> custom.ModelManagerSP.Chunk:
|
||||
chunk = custom.ModelManagerSP.Chunk()
|
||||
chunk.fileName = chunk_data.get("file_name")
|
||||
chunk.sha256 = chunk_data.get("sha256")
|
||||
return chunk
|
||||
|
||||
@staticmethod
|
||||
def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact:
|
||||
artifact = custom.ModelManagerSP.Artifact()
|
||||
artifact.fileName = artifact_data.get("file_name")
|
||||
artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {}))
|
||||
|
||||
if "chunks" in artifact_data:
|
||||
artifact.chunks = [ModelParser._parse_chunk(chunk_data) for chunk_data in artifact_data["chunks"]]
|
||||
|
||||
try:
|
||||
model_dir = Paths.model_root()
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
manifest_path = os.path.join(model_dir, f"{artifact.fileName}.chunkmanifest")
|
||||
num_chunks = str(len(artifact.chunks))
|
||||
|
||||
if not os.path.exists(manifest_path) or open(manifest_path).read().strip() != num_chunks:
|
||||
with open(manifest_path, "w") as f:
|
||||
f.write(num_chunks)
|
||||
cloudlog.info(f"Wrote chunk manifest for {artifact.fileName}: {num_chunks} chunks")
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to write chunk manifest for {artifact.fileName}: {e}")
|
||||
|
||||
return artifact
|
||||
|
||||
@staticmethod
|
||||
@@ -138,8 +112,8 @@ class ModelCache:
|
||||
|
||||
class ModelFetcher:
|
||||
"""Handles fetching and caching of model data from remote source"""
|
||||
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v23.json"
|
||||
MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v27.json"
|
||||
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v25.json"
|
||||
MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v28.json"
|
||||
|
||||
MODEL_SOURCES = {
|
||||
"qcom": (MODEL_URL, ""),
|
||||
@@ -270,5 +244,3 @@ if __name__ == "__main__":
|
||||
model_overrides = {override.key: override.value for override in bundle.overrides}
|
||||
print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}")
|
||||
print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}")
|
||||
if model.artifact.chunks:
|
||||
print(f"Contains {len(model.artifact.chunks)} chunks.")
|
||||
|
||||
@@ -16,7 +16,7 @@ from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present
|
||||
|
||||
# SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO
|
||||
REQUIRED_JSON_VERSION = 20
|
||||
REQUIRED_JSON_VERSION = 21
|
||||
|
||||
CUSTOM_MODEL_PATH = Paths.model_root()
|
||||
ModelManager = custom.ModelManagerSP
|
||||
@@ -29,9 +29,8 @@ _LAST_VALIDATED_RAW: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def _compute_hash(file_path: str) -> str | None:
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
try:
|
||||
with open_file_chunked(file_path) as file:
|
||||
with open(file_path, 'rb') as file:
|
||||
return hashlib.file_digest(file, "sha256").hexdigest().lower()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -58,20 +57,11 @@ def is_bundle_version_compatible(bundle: dict) -> bool:
|
||||
|
||||
def _bundle_artifacts(bundle: custom.ModelManagerSP.ModelBundle) -> list[tuple[str, str]]:
|
||||
artifacts = []
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
for model in getattr(bundle, 'models', []) or []:
|
||||
for artifact in (getattr(model, 'artifact', None),):
|
||||
if artifact and getattr(artifact, 'fileName', None):
|
||||
if len(artifact.chunks) > 0:
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
chunk_name = get_chunk_name(artifact.fileName, i, len(artifact.chunks))
|
||||
if getattr(chunk, 'sha256', None):
|
||||
artifacts.append((chunk_name, chunk.sha256))
|
||||
else:
|
||||
if getattr(artifact, 'downloadUri', None):
|
||||
sha256 = getattr(artifact.downloadUri, 'sha256', None)
|
||||
if sha256:
|
||||
artifacts.append((artifact.fileName, sha256))
|
||||
artifact = getattr(model, 'artifact', None)
|
||||
if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None):
|
||||
if sha256 := getattr(artifact.downloadUri, 'sha256', None):
|
||||
artifacts.append((artifact.fileName, sha256))
|
||||
return artifacts
|
||||
|
||||
|
||||
|
||||
@@ -109,50 +109,6 @@ class ModelManagerSP:
|
||||
# Clean up start time after download completes
|
||||
del self._download_start_times[model.fileName]
|
||||
|
||||
async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None:
|
||||
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
|
||||
|
||||
num_chunks = len(artifact.chunks)
|
||||
if num_chunks == 0:
|
||||
raise ValueError("No chunks defined in artifact")
|
||||
|
||||
manifest_path = get_manifest_path(base_path)
|
||||
self._download_start_times[artifact.fileName] = time.monotonic()
|
||||
|
||||
# Shared connection saves a TCP+TLS handshake per chunk.
|
||||
# Keep sequential: the link saturates on one stream and Session is not thread-safe.
|
||||
completed = len(skip)
|
||||
with requests.Session() as session:
|
||||
for i, _ in enumerate(artifact.chunks):
|
||||
if i in skip:
|
||||
continue
|
||||
chunk_url = get_chunk_name(base_url, i, num_chunks)
|
||||
chunk_path = get_chunk_name(base_path, i, num_chunks)
|
||||
chunk_downloaded = 0
|
||||
with session.get(chunk_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
chunk_size = int(response.headers.get("content-length", 0))
|
||||
with open(chunk_path, 'wb') as f: # noqa: ASYNC230
|
||||
for data in response.iter_content(chunk_size=self._chunk_size):
|
||||
f.write(data)
|
||||
chunk_downloaded += len(data)
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
intra = chunk_downloaded / max(chunk_size, 1)
|
||||
progress = min(99.0, ((completed + intra) / num_chunks) * 100)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
|
||||
artifact.downloadProgress.progress = progress
|
||||
artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress)
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
completed += 1
|
||||
|
||||
with open(manifest_path, 'w') as f: # noqa: ASYNC230
|
||||
f.write(str(num_chunks))
|
||||
if os.path.isfile(base_path): # noqa: ASYNC240
|
||||
os.remove(base_path)
|
||||
del self._download_start_times[artifact.fileName]
|
||||
|
||||
async def _process_artifact(self, artifact, destination_path: str) -> None:
|
||||
if not artifact.downloadUri.uri:
|
||||
return None
|
||||
@@ -165,28 +121,7 @@ class ModelManagerSP:
|
||||
full_path = os.path.join(destination_path, filename)
|
||||
|
||||
try:
|
||||
# progress counts only valid chunks so a resumed download continues the
|
||||
# bar from where verification left it, instead of falling back to zero
|
||||
is_cached = False
|
||||
valid_chunks: set[int] = set()
|
||||
if len(artifact.chunks) > 0:
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
num_chunks = len(artifact.chunks)
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256):
|
||||
valid_chunks.add(i)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying
|
||||
artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
is_cached = len(valid_chunks) == num_chunks
|
||||
else:
|
||||
if await verify_file(full_path, expected_hash):
|
||||
is_cached = True
|
||||
|
||||
if is_cached:
|
||||
if await verify_file(full_path, expected_hash):
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
|
||||
artifact.downloadProgress.progress = 100
|
||||
artifact.downloadProgress.eta = 0
|
||||
@@ -194,17 +129,9 @@ class ModelManagerSP:
|
||||
self._report_status()
|
||||
return
|
||||
|
||||
if len(artifact.chunks) > 0:
|
||||
await self._download_chunked(url, full_path, artifact, skip=valid_chunks)
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
chunk_path = get_chunk_name(full_path, i, len(artifact.chunks))
|
||||
if not await verify_file(chunk_path, chunk.sha256):
|
||||
raise ValueError(f"Hash validation failed for chunk {i+1} of {filename}")
|
||||
else:
|
||||
await self._download_file(url, full_path, artifact)
|
||||
if not await verify_file(full_path, expected_hash):
|
||||
raise ValueError(f"Hash validation failed for {filename}")
|
||||
await self._download_file(url, full_path, artifact)
|
||||
if not await verify_file(full_path, expected_hash):
|
||||
raise ValueError(f"Hash validation failed for {filename}")
|
||||
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
artifact.downloadProgress.progress = 100
|
||||
@@ -213,7 +140,6 @@ class ModelManagerSP:
|
||||
self._report_status()
|
||||
|
||||
except DownloadCancelled:
|
||||
# a cancel keeps whatever is on disk: complete chunks resume the next attempt
|
||||
self._download_start_times.pop(artifact.fileName, None)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
@@ -225,9 +151,8 @@ class ModelManagerSP:
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Error downloading {filename}: {str(e)}")
|
||||
for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]:
|
||||
if os.path.isfile(f): # noqa: ASYNC240
|
||||
os.remove(f)
|
||||
if os.path.isfile(full_path): # noqa: ASYNC240
|
||||
os.remove(full_path)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
@@ -360,12 +285,11 @@ class ModelManagerSP:
|
||||
if model.artifact.fileName:
|
||||
active_files.append(model.artifact.fileName)
|
||||
|
||||
# Remove all files except active ones (including their chunk files)
|
||||
# Remove all files except active ones
|
||||
model_dir = Paths.model_root()
|
||||
try:
|
||||
for filename in os.listdir(model_dir):
|
||||
base = filename.split('.chunk')[0] if '.chunk' in filename else filename
|
||||
if base not in active_files and filename not in active_files:
|
||||
if filename not in active_files:
|
||||
file_path = os.path.join(model_dir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
@@ -17,11 +17,9 @@ from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import requests
|
||||
from urllib3.connectionpool import HTTPConnectionPool
|
||||
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
|
||||
from openpilot.selfdrive.test.helpers import http_server_context
|
||||
from openpilot.sunnypilot.models import manager as manager_module
|
||||
from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles
|
||||
@@ -30,7 +28,6 @@ from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_s
|
||||
resolve_bundle_by_ref, validate_active_bundles)
|
||||
from openpilot.sunnypilot.models.manager import ModelManagerSP
|
||||
|
||||
CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000]
|
||||
WHOLE_BODY = b'Z' * 9000
|
||||
|
||||
|
||||
@@ -49,12 +46,7 @@ class DownloadHandler(http.server.BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def _body_for(self, path):
|
||||
if path.endswith('.whole'):
|
||||
return WHOLE_BODY
|
||||
for i in range(len(CHUNK_BODIES)):
|
||||
if path.endswith(get_chunk_name('', i, len(CHUNK_BODIES))):
|
||||
return CHUNK_BODIES[i]
|
||||
return None
|
||||
return WHOLE_BODY if path.endswith('.pkl') else None
|
||||
|
||||
def do_GET(self):
|
||||
type(self).request_paths.append(self.path)
|
||||
@@ -119,35 +111,19 @@ class ManagerDownloadTestBase(OpenpilotTestCase):
|
||||
if artifact is not None:
|
||||
self.reported.append(float(artifact.downloadProgress.progress))
|
||||
|
||||
def make_artifact(self, chunked: bool):
|
||||
def make_artifact(self):
|
||||
bundle = custom.ModelManagerSP.ModelBundle.new_message()
|
||||
bundle.init('models', 1)
|
||||
artifact = bundle.models[0].artifact
|
||||
artifact.fileName = 'driving_test_tinygrad.pkl'
|
||||
if chunked:
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl'
|
||||
artifact.downloadUri.sha256 = sha256(b''.join(CHUNK_BODIES))
|
||||
artifact.init('chunks', len(CHUNK_BODIES))
|
||||
for i, body in enumerate(CHUNK_BODIES):
|
||||
artifact.chunks[i].sha256 = sha256(body)
|
||||
else:
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl.whole'
|
||||
artifact.downloadUri.sha256 = sha256(WHOLE_BODY)
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl'
|
||||
artifact.downloadUri.sha256 = sha256(WHOLE_BODY)
|
||||
self._bundle = bundle
|
||||
self.artifact = artifact
|
||||
return artifact
|
||||
|
||||
def chunk_paths(self, base_path):
|
||||
return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))]
|
||||
|
||||
def assert_no_partials(self, base_path):
|
||||
leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path)
|
||||
if os.path.isfile(p)]
|
||||
assert leftovers == [], f"partial files left behind: {leftovers}"
|
||||
|
||||
|
||||
class TestManagerDownload(ManagerDownloadTestBase):
|
||||
"""Exercises the real _download_file / _download_chunked against a local server."""
|
||||
"""Exercises the real _download_file against a local server."""
|
||||
|
||||
def run_with_server(self, fn):
|
||||
with http_server_context(handler=DownloadHandler) as (host, port):
|
||||
@@ -156,7 +132,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
|
||||
def test_download_file_writes_exact_bytes(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=False)
|
||||
artifact = self.make_artifact()
|
||||
path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact))
|
||||
with open(path, 'rb') as f:
|
||||
@@ -166,111 +142,67 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
assert artifact.fileName not in self.manager._download_start_times
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_chunked_writes_all_chunks_and_manifest(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected, f"chunk {i} body mismatch"
|
||||
|
||||
with open(get_manifest_path(base_path)) as f:
|
||||
assert f.read() == str(len(CHUNK_BODIES))
|
||||
|
||||
assert not os.path.isfile(base_path), "base file should be removed after chunking"
|
||||
assert artifact.fileName not in self.manager._download_start_times
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_progress_is_monotonic_and_bounded(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
assert self.reported, "expected progress reports"
|
||||
for a, b in zip(self.reported, self.reported[1:], strict=False):
|
||||
assert b >= a, f"progress went backwards: {a} -> {b}"
|
||||
assert max(self.reported) <= 99.0, f"chunked progress must stay <=99 until verify, got {max(self.reported)}"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_session_is_reused_across_chunks(self):
|
||||
"""One connection pool shared across every chunk."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
|
||||
pools = []
|
||||
original = HTTPConnectionPool.urlopen
|
||||
|
||||
def tracked(pool_self, *args, **kwargs):
|
||||
pools.append(id(pool_self))
|
||||
return original(pool_self, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(HTTPConnectionPool, 'urlopen', tracked):
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
assert len(pools) == len(CHUNK_BODIES), f"expected one request per chunk, got {len(pools)}"
|
||||
assert len(set(pools)) == 1, f"connection pool not reused across chunks: {len(set(pools))} pools"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_http_error_propagates(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
failing = '/' + os.path.basename(get_chunk_name(artifact.downloadUri.uri, 1, len(CHUNK_BODIES)))
|
||||
DownloadHandler.fail_paths = {failing: 404}
|
||||
DownloadHandler.fail_paths = {'/' + artifact.fileName: 404}
|
||||
|
||||
with self.assertRaises(requests.exceptions.HTTPError):
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
# chunk 1 failed, so its file and the manifest must not exist
|
||||
assert not os.path.isfile(get_chunk_name(base_path, 1, len(CHUNK_BODIES)))
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert not os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_cancellation_mid_transfer(self):
|
||||
"""Cancellation is checked inside the byte loop; it must still fire after the port."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.return_value = None # cancelled
|
||||
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_repeat_downloads_are_stable(self):
|
||||
"""Back-to-back runs must produce identical bytes and leak no start-time state."""
|
||||
def body():
|
||||
for _ in range(2):
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
with open(base_path, 'rb') as f:
|
||||
assert f.read() == WHOLE_BODY
|
||||
assert self.manager._download_start_times == {}
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_ref_present_keeps_download_alive(self):
|
||||
"""A pending download request (DownloadRef set) must not be cancelled mid-transfer."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None
|
||||
self.manager._download_ref = b"ref"
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_cancellation_via_download_ref(self):
|
||||
"""Removing DownloadRef mid-transfer cancels the download."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
checks = {"n": 0}
|
||||
|
||||
@@ -283,20 +215,19 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
self.manager.params.get.side_effect = get
|
||||
self.manager._download_ref = b"ref"
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_replaced_download_ref_queues_instead_of_cancelling(self):
|
||||
"""Selecting another model mid-transfer lets the running download finish."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None
|
||||
self.manager._download_ref = b"ref"
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_replaced_download_ref_is_kept(self):
|
||||
@@ -315,11 +246,10 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
def test_cached_bundle_cancel_skips_slot_write(self):
|
||||
"""A cancel must stop an already-on-disk bundle before it is applied to the slot."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
for i, data in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(data)
|
||||
with open(base_path, 'wb') as f:
|
||||
f.write(WHOLE_BODY)
|
||||
self._bundle.ref = "test-ref"
|
||||
params, store = self._make_params_with_store()
|
||||
store["ModelManager_DownloadRef"] = None # removed -> cancelled
|
||||
@@ -329,42 +259,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom"))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert "ModelManager_ActiveBundle" not in store
|
||||
assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_resume_skips_valid_chunks(self):
|
||||
"""A chunk already on disk is kept and not re-downloaded; progress starts above its share."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(CHUNK_BODIES[0])
|
||||
|
||||
asyncio.run(self.manager._process_artifact(artifact, self.dest))
|
||||
|
||||
chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES))
|
||||
assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded"
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_verify_reports_valid_fraction_then_cached(self):
|
||||
"""A fully cached bundle publishes climbing verify progress and ends cached."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
for i, data in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
asyncio.run(self.manager._process_artifact(artifact, self.dest))
|
||||
|
||||
assert DownloadHandler.request_paths == [], "cached bundle must not hit the network"
|
||||
assert [round(p) for p in self.reported[:3]] == [33, 67, 100]
|
||||
assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached
|
||||
assert os.path.isfile(base_path), "cancel must not delete the cached model"
|
||||
self.run_with_server(body)
|
||||
|
||||
def _make_params_with_store(self):
|
||||
@@ -384,7 +279,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
def test_download_writes_qcom_slot(self):
|
||||
"""A download resolved to the qcom source writes the qcom active bundle slot only."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
self._bundle.ref = "test-ref"
|
||||
self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION
|
||||
params, store = self._make_params_with_store()
|
||||
@@ -396,15 +291,13 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref"
|
||||
assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))]
|
||||
missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))]
|
||||
assert missing == [], f"chunks missing from the cache: {missing}"
|
||||
assert os.path.isfile(os.path.join(self.dest, artifact.fileName))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_writes_chestnut_slot(self):
|
||||
"""A download resolved to the chestnut source writes the chestnut active bundle slot only."""
|
||||
def body():
|
||||
self.make_artifact(chunked=True)
|
||||
self.make_artifact()
|
||||
self._bundle.ref = "big-ref"
|
||||
self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION
|
||||
params, store = self._make_params_with_store()
|
||||
@@ -782,7 +675,7 @@ class TestEffectiveSource(OpenpilotTestCase):
|
||||
|
||||
@unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network')
|
||||
class TestLiveModelManifest(OpenpilotTestCase):
|
||||
"""Every artifact and chunk URL in the published manifest must resolve."""
|
||||
"""Every artifact URL in the published manifest must resolve."""
|
||||
|
||||
def test_all_manifest_urls_available(self):
|
||||
from openpilot.sunnypilot.models.fetcher import ModelFetcher
|
||||
@@ -794,10 +687,7 @@ class TestLiveModelManifest(OpenpilotTestCase):
|
||||
for bundle in manifest.get('bundles', []):
|
||||
for model in bundle.get('models', []):
|
||||
artifact = model['artifact']
|
||||
url = artifact['download_uri']['url']
|
||||
chunks = artifact.get('chunks', [])
|
||||
urls = ([url] if not chunks
|
||||
else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))])
|
||||
urls = [artifact['download_uri']['url']]
|
||||
for u in urls:
|
||||
try:
|
||||
r = session.head(u, timeout=15, allow_redirects=True)
|
||||
|
||||
+1
-1
@@ -115,7 +115,7 @@ class IntelligentCruiseButtonManagement:
|
||||
self.is_ready = ready and not button_pressed
|
||||
|
||||
def run(self, CS: car.CarState, CC: car.CarControl, LP_SP: custom.LongitudinalPlanSP, is_metric: bool) -> None:
|
||||
if self.CP_SP.pcmCruiseSpeed or not self.CP_SP.intelligentCruiseButtonManagementAvailable:
|
||||
if self.CP_SP.pcmCruiseSpeed:
|
||||
return
|
||||
|
||||
self.is_metric = is_metric
|
||||
|
||||
@@ -127,10 +127,6 @@ def initialize_params(params) -> list[dict[str, Any]]:
|
||||
keys.extend([
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
"ToyotaTSS2Long",
|
||||
"ToyotaEnhancedBsm",
|
||||
"ToyotaAutoHold",
|
||||
"ToyotaBrakeOnset",
|
||||
])
|
||||
|
||||
return [{k: params.get(k, return_default=True)} for k in keys]
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
from opendbc.can.parser import CANParser
|
||||
from opendbc.car import create_button_events
|
||||
from opendbc.car.structs import car
|
||||
from opendbc.car.toyota.carstate import get_virtual_cruise_button, VIRTUAL_CRUISE_BUTTONS
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.parameterized import parameterized, parameterized_class
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.car.cruise import TOYOTA_VIRTUAL_CRUISE_LONG_PRESS, VCruiseHelper, V_CRUISE_INITIAL, V_CRUISE_UNSET
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_INITIAL
|
||||
from openpilot.selfdrive.car.tests.test_cruise_speed import TestVCruiseHelper
|
||||
from openpilot.sunnypilot.selfdrive.car.interfaces import initialize_params
|
||||
|
||||
ButtonEvent = car.CarState.ButtonEvent
|
||||
ButtonType = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
class TestToyotaParamsHandoff(OpenpilotTestCase):
|
||||
def test_tss2_long_tuning_param_is_forwarded_to_opendbc(self):
|
||||
keys = {next(iter(entry)) for entry in initialize_params(Params())}
|
||||
assert "ToyotaTSS2Long" in keys
|
||||
|
||||
|
||||
# TODO: test pcmCruise and pcmCruiseSpeed
|
||||
@parameterized_class(('pcm_cruise', 'pcm_cruise_speed'), [(False, True)])
|
||||
class TestCustomAccIncrements(TestVCruiseHelper):
|
||||
@@ -126,8 +114,8 @@ class TestCustomAccIncrements(TestVCruiseHelper):
|
||||
def test_rounding_behavior(self):
|
||||
"""Test rounding behavior for 5 and 10 increments"""
|
||||
test_cases = [
|
||||
(47, 5, 50), # 47 -> 50 (round up to next 5)
|
||||
(45, 5, 50), # 45 -> 50 (already at 5, increment by 5)
|
||||
(47, 5, 50), # 47 -> 50 (round up to next 5)
|
||||
(45, 5, 50), # 45 -> 50 (already at 5, increment by 5)
|
||||
(43, 10, 50), # 43 -> 50 (round up to next 10)
|
||||
(40, 10, 50), # 40 -> 50 (already at 10, increment by 10)
|
||||
]
|
||||
@@ -158,302 +146,3 @@ class TestCustomAccIncrements(TestVCruiseHelper):
|
||||
initial_speed = self.v_cruise_helper.v_cruise_kph
|
||||
self.press_button_long(ButtonType.accelCruise)
|
||||
assert self.v_cruise_helper.v_cruise_kph == initial_speed + 10 # Should fallback to 10
|
||||
|
||||
|
||||
class TestToyotaVirtualCruiseSpeed(OpenpilotTestCase):
|
||||
def setup_method(self):
|
||||
self.params = Params()
|
||||
self.params.put_bool("CustomAccIncrementsEnabled", True, block=True)
|
||||
self.params.put("CustomAccShortPressIncrement", 5, block=True)
|
||||
self.params.put("CustomAccLongPressIncrement", 5, block=True)
|
||||
|
||||
CP = car.CarParams(brand="toyota", pcmCruise=True, openpilotLongitudinalControl=True)
|
||||
CP_SP = custom.CarParamsSP(pcmCruiseSpeed=False)
|
||||
self.v_cruise_helper = VCruiseHelper(CP, CP_SP)
|
||||
self.v_cruise_helper.read_custom_set_speed_params()
|
||||
self.route_parser = CANParser("toyota_nodsu_pt_generated", [("CLUTCH", 16)], 0)
|
||||
self.route_button = 0
|
||||
|
||||
@staticmethod
|
||||
def car_state(canonical_kph, cluster_kph, *, available=True, standstill=False, gas_pressed=False, v_ego_kph=0.0, button_events=None):
|
||||
CS = car.CarState(
|
||||
gasPressed=gas_pressed,
|
||||
vEgo=v_ego_kph * CV.KPH_TO_MS,
|
||||
cruiseState={
|
||||
"available": available,
|
||||
"speed": canonical_kph * CV.KPH_TO_MS,
|
||||
"speedCluster": cluster_kph * CV.KPH_TO_MS,
|
||||
"standstill": standstill,
|
||||
},
|
||||
)
|
||||
CS.buttonEvents = button_events or []
|
||||
return CS
|
||||
|
||||
def seed_enabled(self, canonical_kph, cluster_kph, *, is_metric=True):
|
||||
CS = self.car_state(canonical_kph, cluster_kph)
|
||||
self.v_cruise_helper.update_v_cruise(CS, enabled=False, is_metric=is_metric)
|
||||
self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=is_metric)
|
||||
self.v_cruise_helper.update_v_cruise(CS, enabled=True, is_metric=is_metric)
|
||||
assert self.v_cruise_helper.v_cruise_kph == canonical_kph
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == cluster_kph
|
||||
|
||||
def press(self, button_type, canonical_kph, cluster_kph, hold_frames=0, *, standstill=False, gas_pressed=False, v_ego_kph=0.0, is_metric=True):
|
||||
pressed = [ButtonEvent(type=button_type, pressed=True)]
|
||||
self.v_cruise_helper.update_v_cruise(
|
||||
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph, button_events=pressed),
|
||||
enabled=True,
|
||||
is_metric=is_metric,
|
||||
)
|
||||
for _ in range(hold_frames):
|
||||
self.v_cruise_helper.update_v_cruise(
|
||||
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph),
|
||||
enabled=True,
|
||||
is_metric=is_metric,
|
||||
)
|
||||
released = [ButtonEvent(type=button_type, pressed=False)]
|
||||
self.v_cruise_helper.update_v_cruise(
|
||||
self.car_state(canonical_kph, cluster_kph, standstill=standstill, gas_pressed=gas_pressed, v_ego_kph=v_ego_kph, button_events=released),
|
||||
enabled=True,
|
||||
is_metric=is_metric,
|
||||
)
|
||||
|
||||
def set_increments(self, short_increment, long_increment):
|
||||
self.params.put("CustomAccShortPressIncrement", short_increment, block=True)
|
||||
self.params.put("CustomAccLongPressIncrement", long_increment, block=True)
|
||||
self.v_cruise_helper.read_custom_set_speed_params()
|
||||
|
||||
def assert_kph_almost_equal(self, actual, expected):
|
||||
self.assertAlmostEqual(actual, expected, delta=abs(expected) * 1e-6)
|
||||
|
||||
def route_button_events(self, payload):
|
||||
self.route_parser.update((1, [(0x361, bytes.fromhex(payload), 0)]))
|
||||
current = get_virtual_cruise_button(
|
||||
self.route_parser.vl["CLUTCH"]["CRUISE_RES"],
|
||||
self.route_parser.vl["CLUTCH"]["CRUISE_SET"],
|
||||
)
|
||||
events = create_button_events(current, self.route_button, VIRTUAL_CRUISE_BUTTONS)
|
||||
self.route_button = current
|
||||
return events
|
||||
|
||||
def test_short_press_rounds_display_target_and_preserves_offset(self):
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.accelCruise, 28, 32)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
|
||||
|
||||
def test_decel_at_display_minimum_does_not_increase_target(self):
|
||||
self.seed_enabled(26, 30)
|
||||
self.press(ButtonType.decelCruise, 25, 29)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 26
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 30
|
||||
|
||||
@parameterized.expand((52, TOYOTA_VIRTUAL_CRUISE_LONG_PRESS - 1))
|
||||
def test_route_length_short_press_is_not_a_long_press(self, hold_frames):
|
||||
self.set_increments(short_increment=2, long_increment=5)
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.accelCruise, 28, 32, hold_frames=hold_frames)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 29
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 33
|
||||
|
||||
def test_toyota_long_press_uses_route_validated_cadence_and_suppresses_release(self):
|
||||
self.set_increments(short_increment=2, long_increment=5)
|
||||
self.seed_enabled(27, 31)
|
||||
|
||||
pressed = [ButtonEvent(type=ButtonType.accelCruise, pressed=True)]
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=pressed), enabled=True, is_metric=True)
|
||||
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35), enabled=True, is_metric=True)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
|
||||
|
||||
released = [ButtonEvent(type=ButtonType.accelCruise, pressed=False)]
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=released), enabled=True, is_metric=True)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
|
||||
|
||||
def test_route_4_32_second_hold_repeats_six_times(self):
|
||||
self.seed_enabled(26, 30)
|
||||
self.press(ButtonType.accelCruise, 30, 34, hold_frames=432)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 56
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 60
|
||||
|
||||
def test_maximum_boundary_caps_pair_and_preserves_offset(self):
|
||||
self.seed_enabled(141, 145)
|
||||
self.press(ButtonType.accelCruise, 142, 146)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 141
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 145
|
||||
|
||||
self.press(ButtonType.accelCruise, 143, 147)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 141
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 145
|
||||
|
||||
@parameterized.expand(
|
||||
(
|
||||
(25, 29, ButtonType.decelCruise),
|
||||
(141, 147, ButtonType.accelCruise),
|
||||
)
|
||||
)
|
||||
def test_out_of_range_raw_pair_is_not_moved_in_opposite_direction(self, canonical_kph, cluster_kph, button_type):
|
||||
self.seed_enabled(canonical_kph, cluster_kph)
|
||||
self.press(button_type, canonical_kph, cluster_kph)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == canonical_kph
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == cluster_kph
|
||||
|
||||
def test_imperial_increment_preserves_canonical_cluster_pair(self):
|
||||
self.seed_enabled(45, 50, is_metric=False)
|
||||
self.press(ButtonType.accelCruise, 46, 51, is_metric=False)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 51
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 56
|
||||
|
||||
def test_engagement_button_held_does_not_change_target(self):
|
||||
initial = self.car_state(27, 31)
|
||||
self.v_cruise_helper.update_v_cruise(initial, enabled=False, is_metric=True)
|
||||
|
||||
pressed = [ButtonEvent(type=ButtonType.decelCruise, pressed=True)]
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=False, is_metric=True)
|
||||
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS + 10):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
|
||||
released = [ButtonEvent(type=ButtonType.decelCruise, pressed=False)]
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, button_events=released), enabled=True, is_metric=True)
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 28
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 32
|
||||
|
||||
def test_delayed_pcm_target_seeds_before_software_ownership(self):
|
||||
invalid = self.car_state(0, 0)
|
||||
self.v_cruise_helper.update_v_cruise(invalid, enabled=False, is_metric=True)
|
||||
|
||||
release = [ButtonEvent(type=ButtonType.decelCruise, pressed=False)]
|
||||
for _ in range(4):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(0, 0, button_events=release), enabled=True, is_metric=True)
|
||||
assert self.v_cruise_helper.v_cruise_kph == V_CRUISE_UNSET
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == V_CRUISE_UNSET
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31), enabled=True, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 27)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 31)
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 27)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 31)
|
||||
|
||||
def test_route_payload_short_press_drives_virtual_target(self):
|
||||
self.seed_enabled(27, 31)
|
||||
|
||||
pressed = self.route_button_events("a61a0000561a1a81")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=True, is_metric=True)
|
||||
for _ in range(52):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
|
||||
released = self.route_button_events("861a0000561b1a81")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, button_events=released), enabled=True, is_metric=True)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 35
|
||||
|
||||
def test_prius_route_payload_short_set_drives_virtual_target(self):
|
||||
self.seed_enabled(31, 35)
|
||||
|
||||
pressed = self.route_button_events("965f000056666585")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(31, 35, button_events=pressed), enabled=True, is_metric=True)
|
||||
for _ in range(45):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(30, 34), enabled=True, is_metric=True)
|
||||
|
||||
released = self.route_button_events("865f000056666585")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(30, 34, button_events=released), enabled=True, is_metric=True)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 26
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 30
|
||||
|
||||
def test_prius_route_payload_standstill_res_does_not_change_target(self):
|
||||
self.seed_enabled(27, 31)
|
||||
|
||||
pressed = self.route_button_events("a61b0000561c1c80")
|
||||
self.v_cruise_helper.update_v_cruise(
|
||||
self.car_state(27, 31, standstill=True, button_events=pressed),
|
||||
enabled=True,
|
||||
is_metric=True,
|
||||
)
|
||||
for _ in range(TOYOTA_VIRTUAL_CRUISE_LONG_PRESS):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, standstill=True), enabled=True, is_metric=True)
|
||||
|
||||
released = self.route_button_events("865f000056666585")
|
||||
self.v_cruise_helper.update_v_cruise(
|
||||
self.car_state(27, 31, standstill=True, button_events=released),
|
||||
enabled=True,
|
||||
is_metric=True,
|
||||
)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 27
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 31
|
||||
|
||||
def test_route_payload_disengage_mid_hold_clears_pending_action(self):
|
||||
self.seed_enabled(27, 31)
|
||||
|
||||
pressed = self.route_button_events("a61a0000561a1a81")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(27, 31, button_events=pressed), enabled=True, is_metric=True)
|
||||
for _ in range(30):
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
|
||||
released = self.route_button_events("861a0000561b1a81")
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32, available=False, button_events=released), enabled=False, is_metric=True)
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=True, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
|
||||
|
||||
def test_standstill_resume_does_not_change_target(self):
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.accelCruise, 27, 31, standstill=True)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 27
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 31
|
||||
|
||||
def test_disengagement_discards_virtual_target_and_reseeds_raw_pair(self):
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.accelCruise, 28, 32)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
|
||||
raw = self.car_state(28, 32)
|
||||
self.v_cruise_helper.update_v_cruise(raw, enabled=False, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(raw, enabled=True, is_metric=True)
|
||||
self.v_cruise_helper.update_v_cruise(raw, enabled=True, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
|
||||
|
||||
def test_unavailable_and_mads_handback_discard_virtual_target(self):
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.accelCruise, 28, 32)
|
||||
assert self.v_cruise_helper.v_cruise_kph == 31
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(28, 32), enabled=False, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 28)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 32)
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(0, 0, available=False), enabled=False, is_metric=True)
|
||||
assert self.v_cruise_helper.v_cruise_kph == V_CRUISE_UNSET
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == V_CRUISE_UNSET
|
||||
|
||||
self.v_cruise_helper.update_v_cruise(self.car_state(29, 33), enabled=False, is_metric=True)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_kph, 29)
|
||||
self.assert_kph_almost_equal(self.v_cruise_helper.v_cruise_cluster_kph, 33)
|
||||
|
||||
def test_set_during_gas_override_clips_target_to_ego_speed(self):
|
||||
self.seed_enabled(27, 31)
|
||||
self.press(ButtonType.decelCruise, 26, 30, gas_pressed=True, v_ego_kph=50)
|
||||
|
||||
assert self.v_cruise_helper.v_cruise_kph == 50
|
||||
assert self.v_cruise_helper.v_cruise_cluster_kph == 54
|
||||
|
||||
@@ -12,13 +12,11 @@ from openpilot.cereal import log, custom
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
|
||||
from openpilot.sunnypilot import PARAMS_UPDATE_PERIOD
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.blinker_pause_lateral import BlinkerPauseLateral
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.latcontrol_torque_v0 import LatControlTorque as LatControlTorqueV0
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.stopping_controller import StoppingController
|
||||
|
||||
|
||||
class ControlsExt(ModelStateBase):
|
||||
@@ -33,9 +31,6 @@ class ControlsExt(ModelStateBase):
|
||||
self.CP_SP = messaging.log_from_bytes(params.get("CarParamsSP", block=True), custom.CarParamsSP)
|
||||
cloudlog.info("controlsd_ext got CarParamsSP")
|
||||
|
||||
self.stopping_controller = StoppingController(self.CP.stopAccel) if params.get_bool("SunnypilotStoppingController") else None
|
||||
self._stopping_prev = (LongCtrlState.off, 0.0)
|
||||
|
||||
self.sm_services_ext = ['radarState', 'selfdriveStateSP']
|
||||
self.pm_services_ext = ['carControlSP']
|
||||
|
||||
@@ -52,27 +47,6 @@ class ControlsExt(ModelStateBase):
|
||||
else:
|
||||
return lac
|
||||
|
||||
def long_accel_sp(self, actuators, CS, long_plan, accel_limits: tuple[float, float]) -> None:
|
||||
"""sunnypilot: terminal-stop policy applied after the stock LongControl.update() (see stopping_controller.py)."""
|
||||
stock_state = self.LoC.long_control_state
|
||||
stock_accel = actuators.accel
|
||||
prev_state, prev_accel = self._stopping_prev
|
||||
self._stopping_prev = (stock_state, stock_accel)
|
||||
|
||||
if self.stopping_controller is None:
|
||||
return
|
||||
|
||||
state, accel = self.stopping_controller.update(
|
||||
prev_state, stock_state, CS, long_plan.aTarget, prev_accel, stock_accel, accel_limits, long_plan.hasLead,
|
||||
pitch=self.calibrated_pose.orientation.pitch if self.calibrated_pose is not None else None,
|
||||
a_long=self.calibrated_pose.acceleration.x if self.calibrated_pose is not None else None)
|
||||
if state != stock_state:
|
||||
self.LoC.reset()
|
||||
self.LoC.long_control_state = state
|
||||
self.LoC.last_output_accel = accel
|
||||
self._stopping_prev = (state, accel)
|
||||
actuators.accel = float(accel)
|
||||
|
||||
def get_params_sp(self, sm: messaging.SubMaster) -> None:
|
||||
if time.monotonic() - self._param_update_time > PARAMS_UPDATE_PERIOD:
|
||||
self.blinker_pause_lateral.get_params()
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.sunnypilot import get_sanitize_int_param
|
||||
|
||||
AccelProfile = custom.LongitudinalPlanSP.AccelController.Profile
|
||||
|
||||
MAX_ACCEL_BREAKPOINTS = [0., 3., 12, 24., 36.] # m/s
|
||||
MAX_ACCEL_PROFILES = {
|
||||
AccelProfile.eco: [1.75, 1.50, 0.40, 0.15, 0.12],
|
||||
AccelProfile.normal: [1.95, 1.80, 0.70, 0.36, 0.25],
|
||||
AccelProfile.sport: [2.00, 2.00, 1.20, 0.70, 0.50],
|
||||
}
|
||||
ECO_ENGINE_OFF_BP = [0., 3., 12., 16.67, 19.44, 22.22, 24., 36.] # m/s
|
||||
ECO_ENGINE_OFF_MAX_ACCEL = [1.75, 1.50, 0.35, 0.25, 0.16, 0.12, 0.08, 0.06]
|
||||
|
||||
CRUISE_DECEL_RESPONSE_TIME = {
|
||||
AccelProfile.normal: 3.5,
|
||||
AccelProfile.sport: 3.0,
|
||||
}
|
||||
CRUISE_DECEL_ACCEL = {
|
||||
AccelProfile.normal: -0.50,
|
||||
AccelProfile.sport: -0.65,
|
||||
}
|
||||
ECO_CRUISE_DECEL_BP = [0., 16.67, 19.44, 25.0, 33.3] # m/s
|
||||
ECO_CRUISE_DECEL_V = [-0.40, -0.40, -0.22, -0.30, -0.40] # m/s^2
|
||||
ECO_COAST_BLEND_BP = [16.67, 19.44] # m/s
|
||||
ECO_COAST_MIN, ECO_COAST_MAX = -1.2, -0.05 # m/s^2
|
||||
ECO_BOOST_DV_BP = [0.3, 2.5] # m/s, lead speed minus ego speed
|
||||
ECO_BOOST_V = [0.0, 0.35] # m/s^2 added to the eco limit
|
||||
ECO_BOOST_FADE_BP = [19.44, 22.22] # m/s (70 -> 80 km/h): full -> none
|
||||
ECO_BOOST_D_MAX = 80.0 # m, only a lead this close counts
|
||||
ECO_BOOST_PROB_MIN = 0.5
|
||||
ECO_BOOST_RISE = 0.25 # m/s^3, how fast the boost builds
|
||||
ECO_BOOST_FALL = 0.5 # m/s^3, how fast it fades
|
||||
ECO_BOOST_DT = 0.05 # s, planner step
|
||||
CRUISE_DECEL_TAPER_TIME = 1.0 # s; inside |decel| * this of the target the decel eases off proportionally (no overshoot)
|
||||
|
||||
|
||||
class AccelController:
|
||||
def __init__(self):
|
||||
self.params = Params()
|
||||
self._cruise_decel: float | None = None
|
||||
self._cruise_decel_target: float | None = None
|
||||
self._boost = 0.0
|
||||
self.update()
|
||||
|
||||
def update(self) -> None:
|
||||
self._profile = get_sanitize_int_param("AccelPersonality", AccelProfile.eco, AccelProfile.sport, self.params)
|
||||
self._enabled = self.params.get_bool("AccelPersonalityEnabled")
|
||||
|
||||
@property
|
||||
def profile(self) -> int:
|
||||
return self._profile
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get_max_accel(self, v_ego: float, engine_off: bool = False, lead=None) -> float:
|
||||
if engine_off and self._profile == AccelProfile.eco:
|
||||
base = float(np.interp(max(0.0, v_ego), ECO_ENGINE_OFF_BP, ECO_ENGINE_OFF_MAX_ACCEL))
|
||||
else:
|
||||
base = float(np.interp(max(0.0, v_ego), MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[self._profile]))
|
||||
if self._profile != AccelProfile.eco:
|
||||
self._boost = 0.0
|
||||
return base
|
||||
return base + self._eco_lead_boost(v_ego, base, lead)
|
||||
|
||||
def _eco_lead_boost(self, v_ego: float, base: float, lead) -> float:
|
||||
target = 0.0
|
||||
if lead is not None and lead.present and lead.modelProb > ECO_BOOST_PROB_MIN and lead.dRel < ECO_BOOST_D_MAX:
|
||||
target = float(np.interp(lead.vLead - v_ego, ECO_BOOST_DV_BP, ECO_BOOST_V))
|
||||
target *= float(np.interp(v_ego, ECO_BOOST_FADE_BP, [1.0, 0.0]))
|
||||
normal = float(np.interp(max(0.0, v_ego), MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.normal]))
|
||||
target = float(np.clip(target, 0.0, max(normal - base, 0.0)))
|
||||
step = (ECO_BOOST_RISE if target > self._boost else ECO_BOOST_FALL) * ECO_BOOST_DT
|
||||
self._boost = float(np.clip(target, self._boost - step, self._boost + step))
|
||||
return self._boost
|
||||
|
||||
def get_cruise_target(self, v_ego: float, v_target: float, accel_coast: float | None = None) -> float:
|
||||
if not np.isfinite(v_target) or v_target <= 0.0 or v_target >= v_ego:
|
||||
self._cruise_decel = None
|
||||
self._cruise_decel_target = None
|
||||
return v_target
|
||||
|
||||
target_delta = v_target - v_ego
|
||||
if self._profile == AccelProfile.eco:
|
||||
decel = float(np.interp(v_ego, ECO_CRUISE_DECEL_BP, ECO_CRUISE_DECEL_V))
|
||||
if accel_coast is not None and np.isfinite(accel_coast) and accel_coast < 0.0:
|
||||
coast = float(np.clip(accel_coast, ECO_COAST_MIN, ECO_COAST_MAX))
|
||||
w = float(np.interp(v_ego, ECO_COAST_BLEND_BP, [0.0, 1.0]))
|
||||
decel = (1.0 - w) * ECO_CRUISE_DECEL_V[1] + w * coast
|
||||
else:
|
||||
if self._cruise_decel is None or self._cruise_decel_target != v_target:
|
||||
self._cruise_decel = min(CRUISE_DECEL_ACCEL[self._profile], target_delta / CRUISE_DECEL_RESPONSE_TIME[self._profile])
|
||||
self._cruise_decel_target = v_target
|
||||
decel = self._cruise_decel
|
||||
|
||||
decel = max(decel, target_delta / CRUISE_DECEL_TAPER_TIME)
|
||||
return float(v_ego + decel)
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
|
||||
from opendbc.car.interfaces import ACCEL_MAX
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
|
||||
AccelController, AccelProfile, CRUISE_DECEL_ACCEL, CRUISE_DECEL_RESPONSE_TIME, ECO_CRUISE_DECEL_BP,
|
||||
ECO_CRUISE_DECEL_V, ECO_ENGINE_OFF_MAX_ACCEL, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
|
||||
)
|
||||
|
||||
|
||||
class TestAccelController(OpenpilotTestCase):
|
||||
def setUp(self):
|
||||
self.params = Params()
|
||||
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
|
||||
self.params.put("AccelPersonality", AccelProfile.normal, block=True)
|
||||
|
||||
def set_profile(self, profile: int) -> AccelController:
|
||||
self.params.put("AccelPersonality", profile, block=True)
|
||||
return AccelController()
|
||||
|
||||
def test_table_breakpoints(self):
|
||||
for profile, values in MAX_ACCEL_PROFILES.items():
|
||||
controller = self.set_profile(profile)
|
||||
for speed, expected in zip(MAX_ACCEL_BREAKPOINTS, values, strict=True):
|
||||
assert controller.get_max_accel(speed) == expected
|
||||
|
||||
def test_profiles_are_ordered_monotonic_and_bounded(self):
|
||||
controllers = {
|
||||
profile: self.set_profile(profile)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
|
||||
}
|
||||
previous = {profile: float("inf") for profile in controllers}
|
||||
|
||||
for speed in np.linspace(0.0, 55.0, 551):
|
||||
values = {profile: controller.get_max_accel(speed) for profile, controller in controllers.items()}
|
||||
assert 0.0 <= values[AccelProfile.eco] <= values[AccelProfile.normal] <= values[AccelProfile.sport] <= ACCEL_MAX
|
||||
for profile, value in values.items():
|
||||
assert value <= previous[profile]
|
||||
previous[profile] = value
|
||||
|
||||
def test_engine_off_line_only_lowers_eco(self):
|
||||
speeds = np.linspace(0.0, 40.0, 401)
|
||||
eco = self.set_profile(AccelProfile.eco)
|
||||
for speed in speeds:
|
||||
assert eco.get_max_accel(speed, engine_off=True) <= eco.get_max_accel(speed) + 1e-12
|
||||
for speed, expected in zip(MAX_ACCEL_BREAKPOINTS, ECO_ENGINE_OFF_MAX_ACCEL, strict=True):
|
||||
assert eco.get_max_accel(speed, engine_off=True) == expected
|
||||
|
||||
for profile in (AccelProfile.normal, AccelProfile.sport):
|
||||
controller = self.set_profile(profile)
|
||||
for speed in speeds:
|
||||
assert controller.get_max_accel(speed, engine_off=True) == controller.get_max_accel(speed)
|
||||
|
||||
def test_engine_off_eco_line_respects_hybrid_power_budget(self):
|
||||
controller = self.set_profile(AccelProfile.eco)
|
||||
for kph, budget_kw in ((60, 11.5), (70, 11.5), (80, 13.5)):
|
||||
speed = kph / 3.6
|
||||
road_load = (0.010 * 1500 * 9.81 + 0.5 * 1.2 * 0.82 * speed * speed) * speed
|
||||
wheel_power = 1500 * controller.get_max_accel(speed, engine_off=True) * speed + road_load
|
||||
assert wheel_power <= budget_kw * 1e3, kph
|
||||
|
||||
def test_lead_boost_is_eco_only_and_absent_without_a_lead(self):
|
||||
# the eco lead pull-away boost: no lead is the plain table lookup, and normal/sport ignore the lead entirely
|
||||
eco = self.set_profile(AccelProfile.eco)
|
||||
assert eco.get_max_accel(1.0, lead=None) == eco.get_max_accel(1.0)
|
||||
assert eco.get_max_accel(20.0, lead=None) == eco.get_max_accel(20.0)
|
||||
|
||||
normal = self.set_profile(AccelProfile.normal)
|
||||
lead = SimpleNamespace(present=True, modelProb=0.9, dRel=30.0, vLead=23.0)
|
||||
assert normal.get_max_accel(20.0, lead=lead) == normal.get_max_accel(20.0)
|
||||
|
||||
def test_sport_uses_openpilot_accel_max_at_launch(self):
|
||||
controller = self.set_profile(AccelProfile.sport)
|
||||
assert controller.get_max_accel(0.0) == ACCEL_MAX
|
||||
|
||||
def test_negative_speed_uses_standstill_value(self):
|
||||
controller = self.set_profile(AccelProfile.sport)
|
||||
assert controller.get_max_accel(-1.0) == MAX_ACCEL_PROFILES[AccelProfile.sport][0]
|
||||
|
||||
def test_eco_cruise_decel_is_speed_scheduled_not_gap_scheduled(self):
|
||||
controller = self.set_profile(AccelProfile.eco)
|
||||
a_small = controller.get_cruise_target(22.0, 20.0) - 22.0
|
||||
a_big = controller.get_cruise_target(22.0, 12.0) - 22.0
|
||||
assert np.isclose(a_small, a_big)
|
||||
assert np.isclose(controller.get_cruise_target(19.44, 10.0) - 19.44, -0.22)
|
||||
assert np.isclose(controller.get_cruise_target(15.0, 10.0) - 15.0, -0.40)
|
||||
assert -0.40 < controller.get_cruise_target(18.0, 10.0) - 18.0 < -0.22
|
||||
# with the planner's coast accel available, >= 70 km/h coasts exactly (pitch-aware), 60-70 blends into it
|
||||
assert np.isclose(controller.get_cruise_target(22.0, 10.0, accel_coast=-0.27) - 22.0, -0.27)
|
||||
assert np.isclose(controller.get_cruise_target(15.0, 10.0, accel_coast=-0.27) - 15.0, -0.40)
|
||||
assert -0.40 < controller.get_cruise_target(18.0, 10.0, accel_coast=-0.27) - 18.0 < -0.27
|
||||
# normal ignores the coast value
|
||||
normal = self.set_profile(AccelProfile.normal)
|
||||
assert normal.get_cruise_target(22.0, 20.0, accel_coast=-0.27) == normal.get_cruise_target(22.0, 20.0)
|
||||
def test_normal_and_sport_cruise_decel_latch_then_taper(self):
|
||||
for profile in (AccelProfile.normal, AccelProfile.sport):
|
||||
controller = self.set_profile(profile)
|
||||
v_target = 10.0
|
||||
a1 = controller.get_cruise_target(14.0, v_target) - 14.0
|
||||
a2 = controller.get_cruise_target(13.0, v_target) - 13.0
|
||||
expected = min(CRUISE_DECEL_ACCEL[profile], (v_target - 14.0) / CRUISE_DECEL_RESPONSE_TIME[profile])
|
||||
assert a1 < 0 and np.isclose(a1, a2)
|
||||
assert np.isclose(a1, expected)
|
||||
|
||||
a_close = controller.get_cruise_target(10.2, v_target) - 10.2
|
||||
assert a1 < a_close < 0
|
||||
assert controller.get_cruise_target(10.0, v_target) == v_target
|
||||
# A new, lower target re-latches a firmer deceleration.
|
||||
a_new_target = controller.get_cruise_target(14.0, v_target - 2.0) - 14.0
|
||||
assert a_new_target <= a1
|
||||
assert CRUISE_DECEL_RESPONSE_TIME[profile] >= 3.0
|
||||
|
||||
def test_non_decel_cruise_requests_clear_latched_deceleration(self):
|
||||
controller = self.set_profile(AccelProfile.normal)
|
||||
controller.get_cruise_target(14.0, 10.0)
|
||||
assert controller.get_cruise_target(10.0, 10.0) == 10.0
|
||||
assert np.isclose(controller.get_cruise_target(14.0, 10.0) - 14.0,
|
||||
(10.0 - 14.0) / CRUISE_DECEL_RESPONSE_TIME[AccelProfile.normal])
|
||||
|
||||
def test_eco_decel_table_is_valid(self):
|
||||
assert len(ECO_CRUISE_DECEL_BP) == len(ECO_CRUISE_DECEL_V)
|
||||
assert all(decel < 0.0 for decel in ECO_CRUISE_DECEL_V)
|
||||
|
||||
def test_params_refresh(self):
|
||||
controller = self.set_profile(AccelProfile.normal)
|
||||
self.params.put("AccelPersonality", AccelProfile.sport, block=True)
|
||||
controller.update()
|
||||
assert controller.profile == AccelProfile.sport
|
||||
|
||||
self.params.put_bool("AccelPersonalityEnabled", False, block=True)
|
||||
controller.update()
|
||||
assert not controller.is_enabled()
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.controls.lib.drive_helpers import should_stop
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_planner import A_CRUISE_MAX_BP, J_CRUISE_VALS, get_cruise_accel
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalPlanSource, T_IDXS as T_IDXS_MPC
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
|
||||
AccelController, AccelProfile, ECO_CRUISE_DECEL_BP, ECO_CRUISE_DECEL_V, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PRIUS_TSS2_ROUTE_MODEL, PlantSP
|
||||
|
||||
|
||||
class CarParams:
|
||||
steerRatio = 15.0
|
||||
wheelbase = 2.7
|
||||
|
||||
|
||||
def _set_mpc_acceleration(plant: PlantSP, acceleration: float = 2.0) -> None:
|
||||
def update(_radar_state, **_kwargs):
|
||||
mpc = plant.planner.mpc
|
||||
mpc.source = LongitudinalPlanSource.lead0
|
||||
mpc.v_solution[:] = mpc.x0[1] + acceleration * T_IDXS_MPC
|
||||
mpc.a_solution.fill(acceleration)
|
||||
mpc.j_solution.fill(0.0)
|
||||
|
||||
plant.planner.mpc.update = update
|
||||
|
||||
|
||||
def run_profile(profile: int, *, enabled: bool = True, speed: float = 0.0, v_cruise: float = 30.0,
|
||||
v_cruise_fn: Callable[[int], float] | None = None, e2e: bool = False, steps: int = 120,
|
||||
speed_noise: float = 0.0, seed: int = 0):
|
||||
params = Params()
|
||||
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
|
||||
params.put("AccelPersonality", profile, block=True)
|
||||
controller = AccelController()
|
||||
rng = np.random.default_rng(seed)
|
||||
|
||||
accel = 0.0
|
||||
rows = []
|
||||
for frame in range(steps):
|
||||
target_speed = v_cruise if v_cruise_fn is None else v_cruise_fn(frame)
|
||||
measured = speed + (float(rng.normal(0.0, speed_noise)) if speed_noise else 0.0)
|
||||
accel = get_cruise_accel(e2e, target_speed, measured, accel, 0.0, CarParams(), DT_MDL, 2.0, True)
|
||||
if accel > 0.0:
|
||||
accel = min(accel, controller.get_max_accel(measured))
|
||||
speed = max(0.0, speed + accel * DT_MDL)
|
||||
rows.append((speed, accel, should_stop(speed, accel)))
|
||||
return rows
|
||||
|
||||
|
||||
def run_vehicle_profile(profile: int, duration: float = 80.0, enabled: bool = True, speed: float = 0.0,
|
||||
v_cruise_fn: Callable[[float], float] | None = None):
|
||||
params = Params()
|
||||
params.put_bool("AccelPersonalityEnabled", enabled, block=True)
|
||||
params.put("AccelPersonality", profile, block=True)
|
||||
|
||||
plant = PlantSP(speed=speed, actuator_model=PRIUS_TSS2_ROUTE_MODEL, run_long_control=True)
|
||||
_set_mpc_acceleration(plant)
|
||||
rows = []
|
||||
while plant.current_time < duration:
|
||||
v_cruise = 25.0 if v_cruise_fn is None else v_cruise_fn(plant.current_time)
|
||||
result = plant.step(v_cruise=v_cruise)
|
||||
rows.append((plant.current_time, result["speed"], result["a_target"], result["actuator_command"], result["acceleration"]))
|
||||
return np.asarray(rows)
|
||||
|
||||
|
||||
class TestAccelControllerClosedLoop(OpenpilotTestCase):
|
||||
def test_profiles_are_immediate_smooth_and_clearly_distinct(self):
|
||||
traces = {profile: run_vehicle_profile(profile) for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)}
|
||||
stock = run_vehicle_profile(AccelProfile.normal, enabled=False)
|
||||
|
||||
def crossing(trace, speed):
|
||||
return float(trace[np.flatnonzero(trace[:, 1] >= speed)[0], 0])
|
||||
|
||||
time_to_20 = {profile: crossing(trace, 20.0 * CV.MPH_TO_MS) for profile, trace in traces.items()}
|
||||
time_to_50 = {profile: crossing(trace, 50.0 * CV.MPH_TO_MS) for profile, trace in traces.items()}
|
||||
first_motion = {profile: int(np.flatnonzero(trace[:, 1] > 0.01)[0]) for profile, trace in traces.items()}
|
||||
|
||||
self.assertEqual(len(set(first_motion.values())), 1)
|
||||
self.assertTrue(all(trace[0, 2] > 0.0 and trace[1, 3] > 0.0 for trace in traces.values()))
|
||||
self.assertLess(time_to_20[AccelProfile.eco], 10.0)
|
||||
self.assertLess(time_to_50[AccelProfile.eco], 65.0)
|
||||
self.assertGreaterEqual(time_to_20[AccelProfile.eco] - time_to_20[AccelProfile.normal], 0.5)
|
||||
self.assertGreaterEqual(time_to_20[AccelProfile.normal] - time_to_20[AccelProfile.sport], 0.5)
|
||||
self.assertGreaterEqual(time_to_50[AccelProfile.eco] - time_to_50[AccelProfile.normal], 2.0)
|
||||
self.assertGreaterEqual(time_to_50[AccelProfile.normal] - time_to_50[AccelProfile.sport], 3.0)
|
||||
|
||||
stock_peak_jerk = float(np.max(np.abs(np.diff(stock[:, 3])) / DT_MDL))
|
||||
for profile, trace in traces.items():
|
||||
command_jerk = np.abs(np.diff(trace[:, 3])) / DT_MDL
|
||||
self.assertLessEqual(float(np.max(command_jerk)), stock_peak_jerk + 1e-9, profile)
|
||||
|
||||
settled = np.flatnonzero(trace[:, 1] >= 25.0 - 0.15)
|
||||
self.assertGreater(len(settled), 0)
|
||||
settled_trace = trace[settled[0]:]
|
||||
self.assertGreaterEqual(float(np.min(settled_trace[:, 3])), -0.05)
|
||||
self.assertLessEqual(float(np.max(trace[:, 1])), 25.0 + 1e-9)
|
||||
|
||||
def test_blended_positive_model_request_uses_profile_cruise_cap(self):
|
||||
params = Params()
|
||||
params.put_bool("DynamicExperimentalControl", False, block=True)
|
||||
params.put_bool("AccelPersonalityEnabled", True, block=True)
|
||||
params.put("AccelPersonality", AccelProfile.eco, block=True)
|
||||
|
||||
def request_acceleration(_current_time: float, _speed: float, _acceleration: float) -> tuple[float, bool]:
|
||||
return 2.0, False
|
||||
|
||||
plant = PlantSP(speed=15.0, e2e=True, model_action_fn=request_acceleration)
|
||||
_set_mpc_acceleration(plant)
|
||||
results = [plant.step(v_cruise=35.0) for _ in range(20)]
|
||||
settled = results[-1]
|
||||
eco_limit = 0.85 * float(np.interp(settled["published_v_ego"], MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
|
||||
|
||||
self.assertTrue(settled["controller_active"])
|
||||
self.assertEqual(settled["mpc_source"], LongitudinalPlanSource.cruise)
|
||||
self.assertAlmostEqual(settled["a_target"], eco_limit, delta=0.01)
|
||||
self.assertLess(settled["a_target"], settled["model_action"]["desiredAcceleration"])
|
||||
|
||||
def test_normal_launch_is_faster_than_eco(self):
|
||||
eco = run_profile(AccelProfile.eco, speed=4.0, steps=120)
|
||||
normal = run_profile(AccelProfile.normal, speed=4.0, steps=120)
|
||||
self.assertGreater(normal[-1][0], eco[-1][0])
|
||||
|
||||
def test_zero_speed_stop_request_is_unchanged(self):
|
||||
for e2e in (False, True):
|
||||
stock = run_profile(AccelProfile.normal, enabled=False, speed=20.0, v_cruise=0.0, e2e=e2e, steps=100)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
|
||||
self.assertEqual(run_profile(profile, speed=20.0, v_cruise=0.0, e2e=e2e, steps=100), stock)
|
||||
|
||||
def test_eco_cruise_decel_uses_gentle_speed_schedule(self):
|
||||
params = Params()
|
||||
params.put_bool("AccelPersonalityEnabled", True, block=True)
|
||||
params.put("AccelPersonality", AccelProfile.eco, block=True)
|
||||
controller = AccelController()
|
||||
|
||||
speeds = (15.0, 18.0, 19.44, 25.0, 33.3)
|
||||
decels = [controller.get_cruise_target(speed, speed - 10.0) - speed for speed in speeds]
|
||||
expected = np.interp(speeds, ECO_CRUISE_DECEL_BP, ECO_CRUISE_DECEL_V)
|
||||
np.testing.assert_allclose(decels, expected)
|
||||
self.assertTrue(all(decel > -0.5 for decel in decels))
|
||||
|
||||
def test_normal_cruise_decel_remains_gradual(self):
|
||||
params = Params()
|
||||
params.put_bool("AccelPersonalityEnabled", True, block=True)
|
||||
params.put("AccelPersonality", AccelProfile.normal, block=True)
|
||||
controller = AccelController()
|
||||
target = 25.0 - 5.0 * CV.MPH_TO_MS
|
||||
stock = get_cruise_accel(False, target, 25.0, 0.0, 0.0, CarParams(), DT_MDL, 2.0, True)
|
||||
shaped = controller.get_cruise_target(25.0, target)
|
||||
self.assertLess(shaped, 25.0)
|
||||
self.assertGreater(shaped, target)
|
||||
self.assertLess(shaped - 25.0, 0.0)
|
||||
self.assertLess(shaped - 25.0, -0.3)
|
||||
self.assertGreater(abs(stock), 0.0)
|
||||
|
||||
def test_blended_launch_respects_profiles(self):
|
||||
traces = {
|
||||
profile: run_profile(profile, v_cruise=8.0, e2e=True, steps=180)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
|
||||
}
|
||||
time_to_five = {
|
||||
profile: next(frame for frame, row in enumerate(rows) if row[0] >= 5.0) * DT_MDL
|
||||
for profile, rows in traces.items()
|
||||
}
|
||||
|
||||
self.assertLess(time_to_five[AccelProfile.sport], time_to_five[AccelProfile.normal])
|
||||
self.assertLess(time_to_five[AccelProfile.normal], time_to_five[AccelProfile.eco])
|
||||
|
||||
def test_launch_ordering_without_departure_delay(self):
|
||||
stock = run_profile(AccelProfile.normal, enabled=False, v_cruise=8.0, steps=160)
|
||||
traces = {
|
||||
profile: run_profile(profile, v_cruise=8.0, steps=160)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
|
||||
}
|
||||
first_motion = {
|
||||
profile: next(frame for frame, row in enumerate(rows) if row[0] > 0.01)
|
||||
for profile, rows in traces.items()
|
||||
}
|
||||
time_to_five = {
|
||||
profile: next(frame for frame, row in enumerate(rows) if row[0] >= 5.0) * DT_MDL
|
||||
for profile, rows in traces.items()
|
||||
}
|
||||
stock_first_motion = next(frame for frame, row in enumerate(stock) if row[0] > 0.01)
|
||||
|
||||
self.assertEqual(len(set(first_motion.values())), 1)
|
||||
self.assertTrue(all(frame == stock_first_motion for frame in first_motion.values()))
|
||||
self.assertGreaterEqual(time_to_five[AccelProfile.eco] - time_to_five[AccelProfile.normal], 0.1)
|
||||
self.assertGreaterEqual(time_to_five[AccelProfile.normal], time_to_five[AccelProfile.sport])
|
||||
|
||||
def test_road_speed_catchup_stays_useful(self):
|
||||
traces = {
|
||||
profile: run_profile(profile, speed=20.0, v_cruise=30.0, steps=100)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
|
||||
}
|
||||
gains = {profile: rows[-1][0] - 20.0 for profile, rows in traces.items()}
|
||||
self.assertGreater(gains[AccelProfile.normal] - gains[AccelProfile.eco], 0.05)
|
||||
self.assertGreater(gains[AccelProfile.sport] - gains[AccelProfile.normal], 0.1)
|
||||
|
||||
def test_full_catchup_trace_respects_stock_jerk(self):
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport):
|
||||
rows = run_profile(profile, v_cruise=30.0, steps=300)
|
||||
previous_speed = 0.0
|
||||
previous_accel = 0.0
|
||||
for speed, accel, _should_stop in rows:
|
||||
jerk_step = float(np.interp(previous_speed, A_CRUISE_MAX_BP, J_CRUISE_VALS)) * DT_MDL
|
||||
self.assertLessEqual(abs(accel - previous_accel), jerk_step + 1e-12)
|
||||
previous_speed = speed
|
||||
previous_accel = accel
|
||||
|
||||
def test_stop_release_frame_is_profile_independent(self):
|
||||
def target_speed(frame: int) -> float:
|
||||
return 0.0 if frame < 20 else 8.0
|
||||
|
||||
traces = {
|
||||
profile: run_profile(profile, v_cruise_fn=target_speed, steps=80)
|
||||
for profile in (AccelProfile.eco, AccelProfile.normal, AccelProfile.sport)
|
||||
}
|
||||
release_frames = {
|
||||
profile: next(frame for frame, row in enumerate(rows) if frame >= 20 and not row[2])
|
||||
for profile, rows in traces.items()
|
||||
}
|
||||
stock = run_profile(AccelProfile.normal, enabled=False, v_cruise_fn=target_speed, steps=80)
|
||||
stock_release_frame = next(frame for frame, row in enumerate(stock) if frame >= 20 and not row[2])
|
||||
self.assertEqual(len(set(release_frames.values())), 1)
|
||||
self.assertTrue(all(frame == stock_release_frame for frame in release_frames.values()))
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import (
|
||||
AccelController, AccelProfile, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES,
|
||||
)
|
||||
|
||||
|
||||
class TestEcoLeadBoost(OpenpilotTestCase):
|
||||
"""Eco lead pull-away boost (driver 2026-10-01 night)."""
|
||||
|
||||
@staticmethod
|
||||
def _ac(profile):
|
||||
from types import SimpleNamespace
|
||||
ac = AccelController.__new__(AccelController)
|
||||
ac._profile, ac._enabled, ac._boost = profile, True, 0.0
|
||||
ac._cruise_decel = ac._cruise_decel_target = None
|
||||
return ac, SimpleNamespace
|
||||
|
||||
def test_no_lead_unchanged(self):
|
||||
ac, _ = self._ac(AccelProfile.eco)
|
||||
v = 15.0
|
||||
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
|
||||
for _ in range(100):
|
||||
assert ac.get_max_accel(v, False, None) == base
|
||||
|
||||
def test_pulling_away_lead_boosts_smoothly_within_normal(self):
|
||||
ac, NS = self._ac(AccelProfile.eco)
|
||||
v = 15.0
|
||||
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v + 3.0)
|
||||
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
|
||||
normal = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.normal]))
|
||||
out = [ac.get_max_accel(v, False, lead) for _ in range(60)]
|
||||
steps = np.diff([base] + out)
|
||||
assert max(steps) <= 0.25 * 0.05 + 1e-9 # eased in
|
||||
assert out[-1] > base + 0.2 and out[-1] <= normal + 1e-9
|
||||
lead.present = False
|
||||
out2 = [ac.get_max_accel(v, False, lead) for _ in range(60)]
|
||||
assert min(np.diff([out[-1]] + out2)) >= -0.5 * 0.05 - 1e-9 # eased out
|
||||
assert abs(out2[-1] - base) < 1e-9
|
||||
|
||||
def test_no_boost_at_80_kph_or_for_a_closing_lead(self):
|
||||
ac, NS = self._ac(AccelProfile.eco)
|
||||
v = 80 / 3.6
|
||||
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v + 3.0)
|
||||
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
|
||||
assert abs([ac.get_max_accel(v, False, lead) for _ in range(40)][-1] - base) < 1e-9
|
||||
ac, NS = self._ac(AccelProfile.eco)
|
||||
v = 15.0
|
||||
lead = NS(present=True, modelProb=0.9, dRel=30.0, vLead=v - 1.0)
|
||||
base = float(np.interp(v, MAX_ACCEL_BREAKPOINTS, MAX_ACCEL_PROFILES[AccelProfile.eco]))
|
||||
assert abs([ac.get_max_accel(v, False, lead) for _ in range(40)][-1] - base) < 1e-9
|
||||
|
||||
|
||||
class _Lead:
|
||||
def __init__(self, present=True, modelProb=0.9, dRel=30.0, vLead=18.0):
|
||||
self.present, self.modelProb, self.dRel, self.vLead = present, modelProb, dRel, vLead
|
||||
|
||||
|
||||
class _Radar:
|
||||
def __init__(self, lead):
|
||||
self.leadOne = lead
|
||||
|
||||
|
||||
class _Sink:
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class TestPlannerLeadWiring(OpenpilotTestCase):
|
||||
"""The boost needs a lead, so the planner has to carry radarState.leadOne into get_max_accel_override."""
|
||||
|
||||
def setUp(self):
|
||||
self.params = Params()
|
||||
self.params.put_bool("AccelPersonalityEnabled", True, block=True)
|
||||
self.params.put("AccelPersonality", AccelProfile.eco, block=True)
|
||||
|
||||
@staticmethod
|
||||
def _planner():
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP
|
||||
|
||||
planner = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
|
||||
planner.accel_controller = AccelController()
|
||||
planner.accel_controller_active = False
|
||||
planner.lead_one = None
|
||||
return planner
|
||||
|
||||
@staticmethod
|
||||
def _settle(planner, v_ego, lead=None, n=60):
|
||||
return [planner.get_max_accel_override(v_ego, lead=lead) for _ in range(n)][-1]
|
||||
|
||||
def test_update_stores_the_radar_lead(self):
|
||||
planner = self._planner()
|
||||
planner.events_sp = _Sink()
|
||||
planner.e2e_alerts_helper = _Sink()
|
||||
lead = _Lead()
|
||||
planner.update({"radarState": _Radar(lead)})
|
||||
assert planner.lead_one is lead
|
||||
|
||||
def test_stored_lead_feeds_the_boost_and_an_explicit_lead_wins(self):
|
||||
v = 15.0
|
||||
lead = _Lead(vLead=v + 3.0)
|
||||
planner = self._planner()
|
||||
planner.lead_one = lead
|
||||
boosted = self._settle(planner, v)
|
||||
|
||||
planner.accel_controller._boost = 0.0
|
||||
planner.lead_one = _Lead(present=False)
|
||||
base = self._settle(planner, v)
|
||||
assert boosted > base + 0.2
|
||||
|
||||
planner.accel_controller._boost = 0.0
|
||||
explicit = self._settle(planner, v, lead=lead)
|
||||
assert abs(explicit - boosted) < 1e-9
|
||||
|
||||
def test_disabled_accel_controller_returns_no_override(self):
|
||||
self.params.put_bool("AccelPersonalityEnabled", False, block=True)
|
||||
assert self._planner().get_max_accel_override(15.0, lead=_Lead()) is None
|
||||
@@ -10,8 +10,6 @@ from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
|
||||
|
||||
|
||||
|
||||
class AutoLaneChangeMode:
|
||||
OFF = -1
|
||||
NUDGE = 0 # default
|
||||
@@ -33,7 +31,6 @@ AUTO_LANE_CHANGE_TIMER = {
|
||||
}
|
||||
|
||||
ONE_SECOND_DELAY = -1
|
||||
EDGE_DELAY = -2
|
||||
|
||||
|
||||
class AutoLaneChangeController:
|
||||
@@ -51,7 +48,6 @@ class AutoLaneChangeController:
|
||||
self.prev_brake_pressed = False
|
||||
self.auto_lane_change_allowed = False
|
||||
self.prev_lane_change = False
|
||||
self.lane_change_blocked = False
|
||||
|
||||
self.read_params()
|
||||
|
||||
@@ -72,7 +68,7 @@ class AutoLaneChangeController:
|
||||
self.read_params()
|
||||
self.param_read_counter += 1
|
||||
|
||||
def update_lane_change_timers(self, blindspot_detected: bool, edge_detected: bool) -> None:
|
||||
def update_lane_change_timers(self, blindspot_detected: bool) -> None:
|
||||
self.lane_change_delay = AUTO_LANE_CHANGE_TIMER.get(self.lane_change_set_timer,
|
||||
AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGE])
|
||||
|
||||
@@ -84,12 +80,6 @@ class AutoLaneChangeController:
|
||||
else:
|
||||
self.lane_change_wait_timer = self.lane_change_delay + ONE_SECOND_DELAY
|
||||
|
||||
if edge_detected and self.lane_change_delay > 0:
|
||||
if self.lane_change_delay == AUTO_LANE_CHANGE_TIMER[AutoLaneChangeMode.NUDGELESS]:
|
||||
self.lane_change_wait_timer = min(self.lane_change_wait_timer, EDGE_DELAY)
|
||||
else:
|
||||
self.lane_change_wait_timer = min(self.lane_change_wait_timer, self.lane_change_delay + EDGE_DELAY)
|
||||
|
||||
def update_allowed(self) -> bool:
|
||||
# Auto lane change allowed if:
|
||||
# 1. A valid delay is set (non-zero)
|
||||
@@ -107,23 +97,14 @@ class AutoLaneChangeController:
|
||||
|
||||
return bool(self.lane_change_wait_timer > self.lane_change_delay)
|
||||
|
||||
def update_lane_change(self, blindspot_detected: bool, edge_detected: bool, brake_pressed: bool,
|
||||
torque_applied: bool = False) -> None:
|
||||
def update_lane_change(self, blindspot_detected: bool, brake_pressed: bool) -> None:
|
||||
if brake_pressed and not self.prev_brake_pressed:
|
||||
self.prev_brake_pressed = brake_pressed
|
||||
|
||||
self.update_lane_change_timers(blindspot_detected, edge_detected)
|
||||
self.update_lane_change_timers(blindspot_detected)
|
||||
|
||||
self.auto_lane_change_allowed = self.update_allowed()
|
||||
|
||||
# BSM always blocks. Edge blocks auto mode but nudge can override.
|
||||
if blindspot_detected:
|
||||
self.lane_change_blocked = True
|
||||
elif edge_detected:
|
||||
self.lane_change_blocked = not torque_applied
|
||||
else:
|
||||
self.lane_change_blocked = False
|
||||
|
||||
def update_state(self):
|
||||
if self.DH.lane_change_state == log.LaneChangeState.laneChangeStarting:
|
||||
self.prev_lane_change = True
|
||||
|
||||
@@ -5,13 +5,10 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from openpilot.cereal import messaging, custom, log
|
||||
from openpilot.cereal import messaging, custom
|
||||
from opendbc.car import structs
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_MAX
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.accel_controller.accel_controller import AccelController
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import DynamicExperimentalController
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.e2e_alerts_helper import E2EAlertsHelper
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.smart_cruise_control import SmartCruiseControl
|
||||
@@ -22,17 +19,12 @@ from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
|
||||
DecState = custom.LongitudinalPlanSP.DynamicExperimentalControl.DynamicExperimentalControlState
|
||||
LongitudinalPlanSource = custom.LongitudinalPlanSP.LongitudinalPlanSource
|
||||
MpcPlanSource = log.LongitudinalPlan.LongitudinalPlanSource
|
||||
|
||||
E2E_BRAKE_HOLD_ACCEL = -0.2 # m/s^2
|
||||
|
||||
|
||||
class LongitudinalPlannerSP:
|
||||
def __init__(self, CP: structs.CarParams, CP_SP: structs.CarParamsSP, mpc):
|
||||
self.accel_controller = AccelController()
|
||||
self.accel_controller_active = False
|
||||
self.lead_one = None
|
||||
self.events_sp = EventsSP()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
self.dec = DynamicExperimentalController(CP, mpc)
|
||||
self.scc = SmartCruiseControl()
|
||||
self.resolver = SpeedLimitResolver()
|
||||
@@ -46,48 +38,10 @@ class LongitudinalPlannerSP:
|
||||
|
||||
def is_e2e(self, sm: messaging.SubMaster) -> bool:
|
||||
experimental_mode = sm['selfdriveState'].experimentalMode
|
||||
if not experimental_mode:
|
||||
return False
|
||||
if not self.dec.active():
|
||||
return experimental_mode
|
||||
|
||||
if not self.dec.active() or self.dec.mode() == "blended":
|
||||
return True
|
||||
|
||||
if self.mpc.source == MpcPlanSource.e2e and sm['modelV2'].action.desiredAcceleration < E2E_BRAKE_HOLD_ACCEL:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_max_accel_override(self, v_ego: float, engine_off: bool = False, lead=None) -> float | None:
|
||||
if not self.accel_controller.is_enabled():
|
||||
return None
|
||||
|
||||
return self.accel_controller.get_max_accel(v_ego, engine_off, lead if lead is not None else self.lead_one)
|
||||
|
||||
def get_cruise_target_override(self, v_ego: float, v_target: float, force_decel: bool, accel_coast: float | None = None) -> float:
|
||||
if not self.accel_controller.is_enabled() or force_decel or self.source != LongitudinalPlanSource.cruise:
|
||||
return v_target
|
||||
|
||||
return self.accel_controller.get_cruise_target(v_ego, v_target, accel_coast)
|
||||
|
||||
def is_accel_controller_active(self, force_decel: bool) -> bool:
|
||||
return bool(self.accel_controller.is_enabled() and not force_decel and
|
||||
self.mpc.source == MpcPlanSource.cruise)
|
||||
|
||||
def _has_valid_selected_lead(self, sm: messaging.SubMaster, source: MpcPlanSource) -> bool:
|
||||
radar_valid = sm.valid.get('radarState', False) and getattr(sm, 'alive', {}).get('radarState', False)
|
||||
return radar_valid and ((source == MpcPlanSource.lead0 and sm['radarState'].leadOne.present) or
|
||||
(source == MpcPlanSource.lead1 and sm['radarState'].leadTwo.present))
|
||||
|
||||
def arbitrate_cruise_candidate(self, sm: messaging.SubMaster, gated: float, ungated: float,
|
||||
mpc_accel: float, mpc_source: MpcPlanSource, *, allow_throttle: bool,
|
||||
e2e: bool, force_decel: bool) -> float:
|
||||
finite = all(math.isfinite(value) for value in (gated, ungated, mpc_accel))
|
||||
coast_gate_changed_source = gated < mpc_accel <= ungated
|
||||
if (finite and not allow_throttle and not e2e and not force_decel
|
||||
and self._has_valid_selected_lead(sm, mpc_source) and coast_gate_changed_source):
|
||||
return ungated
|
||||
|
||||
return gated
|
||||
return experimental_mode and self.dec.mode() == "blended"
|
||||
|
||||
def update_targets(self, sm: messaging.SubMaster, v_ego: float, a_ego: float, v_cruise: float) -> tuple[float, float]:
|
||||
CS = sm['carState']
|
||||
@@ -120,13 +74,9 @@ class LongitudinalPlannerSP:
|
||||
return self.output_v_target, self.output_a_target
|
||||
|
||||
def update(self, sm: messaging.SubMaster) -> None:
|
||||
self.accel_controller.update()
|
||||
self.lead_one = sm['radarState'].leadOne
|
||||
self.events_sp.clear()
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
|
||||
def update_dec(self, sm: messaging.SubMaster) -> None:
|
||||
self.dec.update(sm)
|
||||
self.e2e_alerts_helper.update(sm, self.events_sp)
|
||||
|
||||
def publish_longitudinal_plan_sp(self, sm: messaging.SubMaster, pm: messaging.PubMaster) -> None:
|
||||
plan_sp_send = messaging.new_message('longitudinalPlanSP')
|
||||
@@ -144,15 +94,6 @@ class LongitudinalPlannerSP:
|
||||
dec.state = DecState.blended if self.dec.mode() == 'blended' else DecState.acc
|
||||
dec.enabled = self.dec.enabled()
|
||||
dec.active = self.dec.active()
|
||||
#dec.decelIntent = float(self.dec.signals.decel_intent)
|
||||
#dec.curveDetected = bool(self.dec.signals.curve_detected)
|
||||
#dec.wantBlended = bool(self.dec.want_blended)
|
||||
#dec.leadVeto = bool(self.dec.lead_veto)
|
||||
|
||||
accel_controller = longitudinalPlanSP.accelController
|
||||
accel_controller.enabled = bool(self.accel_controller.is_enabled())
|
||||
accel_controller.active = bool(self.accel_controller_active)
|
||||
accel_controller.profile = int(self.accel_controller.profile)
|
||||
|
||||
# Smart Cruise Control
|
||||
smartCruiseControl = longitudinalPlanSP.smartCruiseControl
|
||||
|
||||
+11
-368
@@ -4,8 +4,6 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -17,23 +15,8 @@ from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.car.cruise import V_CRUISE_UNSET
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlannerSP, LongitudinalPlanSource
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control import MIN_V
|
||||
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import (
|
||||
_A_LAT_REG_MAX,
|
||||
_BELOW_EGO_TARGET_RELEASE_RATE,
|
||||
_ENTERING_PRED_LAT_ACC_TH,
|
||||
_MIN_ACTIVATION_SPEED,
|
||||
_RELIEF_CONFIRMATION_FRAMES,
|
||||
_TARGET_RELEASE_CONFIRMATION_FRAMES,
|
||||
_TARGET_RELEASE_RATE,
|
||||
_TARGET_TIGHTEN_CONFIRMATION_FRAMES,
|
||||
_TARGET_TIGHTEN_RATE,
|
||||
_TURNING_LAT_ACC_TH,
|
||||
_URGENT_PRED_LAT_ACC_TH,
|
||||
SmartCruiseControlVision,
|
||||
)
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import SmartCruiseControlVision, _ENTERING_PRED_LAT_ACC_TH
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
VisionState = custom.LongitudinalPlanSP.SmartCruiseControl.VisionState
|
||||
@@ -124,6 +107,7 @@ def generate_controlsState():
|
||||
|
||||
|
||||
class TestSmartCruiseControlVision(OpenpilotTestCase):
|
||||
|
||||
def setup_method(self):
|
||||
self.params = Params()
|
||||
self.reset_params()
|
||||
@@ -137,377 +121,36 @@ class TestSmartCruiseControlVision(OpenpilotTestCase):
|
||||
def reset_params(self):
|
||||
self.params.put_bool("SmartCruiseControlVision", True, block=True)
|
||||
|
||||
def assert_approx(self, actual, expected):
|
||||
self.assertAlmostEqual(actual, expected, delta=max(1e-12, abs(expected) * 1e-6))
|
||||
|
||||
def set_lat_accels(self, current: float, predicted: float, v_ego: float = 20.0, model_speed: float = 20.0) -> None:
|
||||
self.sm['controlsState'].curvature = current / v_ego**2
|
||||
self.sm['modelV2'].velocity.x = [model_speed] * len(ModelConstants.T_IDXS)
|
||||
self.sm['modelV2'].orientationRate.z = [predicted / model_speed] * len(ModelConstants.T_IDXS)
|
||||
|
||||
def update_lat_accels(
|
||||
self, current: float, predicted: float, cruise: float = 30.0, a_ego: float = 0.0, v_ego: float = 20.0, model_speed: float = 20.0
|
||||
) -> None:
|
||||
self.set_lat_accels(current, predicted, v_ego, model_speed)
|
||||
self.scc_v.update(self.sm, True, False, v_ego, a_ego, cruise)
|
||||
|
||||
def enter_curve(self, predicted: float = 2.2) -> None:
|
||||
self.update_lat_accels(0.5, predicted)
|
||||
self.update_lat_accels(0.5, predicted)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
|
||||
def test_initial_state(self):
|
||||
assert self.scc_v.state == VisionState.disabled
|
||||
assert not self.scc_v.is_active
|
||||
assert self.scc_v.output_v_target == V_CRUISE_UNSET
|
||||
assert self.scc_v.output_a_target == 0.0
|
||||
assert self.scc_v.output_a_target == 0.
|
||||
|
||||
def test_system_disabled(self):
|
||||
self.params.put_bool("SmartCruiseControlVision", False, block=True)
|
||||
self.scc_v.enabled = self.params.get_bool("SmartCruiseControlVision")
|
||||
|
||||
for _ in range(int(10.0 / DT_MDL)):
|
||||
self.scc_v.update(self.sm, True, False, 0.0, 0.0, 0.0)
|
||||
for _ in range(int(10. / DT_MDL)):
|
||||
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
|
||||
assert self.scc_v.state == VisionState.disabled
|
||||
assert not self.scc_v.is_active
|
||||
|
||||
def test_disabled(self):
|
||||
for _ in range(int(10.0 / DT_MDL)):
|
||||
self.scc_v.update(self.sm, False, False, 0.0, 0.0, 0.0)
|
||||
for _ in range(int(10. / DT_MDL)):
|
||||
self.scc_v.update(self.sm, False, False, 0., 0., 0.)
|
||||
assert self.scc_v.state == VisionState.disabled
|
||||
|
||||
def test_transition_disabled_to_enabled(self):
|
||||
for _ in range(int(10.0 / DT_MDL)):
|
||||
self.scc_v.update(self.sm, True, False, 0.0, 0.0, 0.0)
|
||||
for _ in range(int(10. / DT_MDL)):
|
||||
self.scc_v.update(self.sm, True, False, 0., 0., 0.)
|
||||
assert self.scc_v.state == VisionState.enabled
|
||||
|
||||
def test_unconfirmed_release_holds_but_urgent_reentry_tightens(self):
|
||||
self.enter_curve()
|
||||
targets = [self.scc_v.output_v_target]
|
||||
|
||||
self.update_lat_accels(2.0, 2.2, a_ego=-0.8)
|
||||
assert self.scc_v.state == VisionState.turning
|
||||
assert self.scc_v.output_a_target == -0.8
|
||||
turning_demand = self.scc_v._v_demand()
|
||||
targets.append(self.scc_v.output_v_target)
|
||||
|
||||
self.update_lat_accels(1.2, 1.2, a_ego=0.3)
|
||||
assert self.scc_v.state == VisionState.leaving
|
||||
assert self.scc_v.output_a_target == 0.3
|
||||
targets.append(self.scc_v.output_v_target)
|
||||
|
||||
self.update_lat_accels(1.0, 3.0, a_ego=-1.2)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert self.scc_v.output_a_target == -1.2
|
||||
reentry_demand = self.scc_v._v_demand()
|
||||
targets.append(self.scc_v.output_v_target)
|
||||
|
||||
entering, turning, leaving, reentering = targets
|
||||
assert turning < entering
|
||||
self.assert_approx(turning, turning_demand)
|
||||
self.assert_approx(leaving, turning)
|
||||
assert reentering < leaving
|
||||
self.assert_approx(reentering, reentry_demand)
|
||||
|
||||
def test_new_curve_interrupts_confirmed_release_immediately(self):
|
||||
self.enter_curve()
|
||||
for _ in range(_RELIEF_CONFIRMATION_FRAMES + 1):
|
||||
self.update_lat_accels(0.8, 0.8)
|
||||
releasing_v_target = self.scc_v.output_v_target
|
||||
assert self.scc_v.state == VisionState.leaving
|
||||
|
||||
self.update_lat_accels(0.8, 3.0, a_ego=-0.7)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert self.scc_v.output_v_target < releasing_v_target
|
||||
assert self.scc_v.output_a_target == -0.7
|
||||
|
||||
@parameterized.expand([(-2.0,), (-0.5,), (0.0,), (0.8,)])
|
||||
def test_planner_acceleration_passes_through_exactly(self, planner_accel):
|
||||
self.enter_curve()
|
||||
self.update_lat_accels(0.5, 2.2, a_ego=planner_accel)
|
||||
assert self.scc_v.output_a_target == planner_accel
|
||||
|
||||
def test_planner_acceleration_passes_through_all_states(self):
|
||||
cases = (
|
||||
(False, False, 0.5, 2.2, -0.2, VisionState.disabled),
|
||||
(True, False, 0.5, 0.8, 0.1, VisionState.enabled),
|
||||
(True, False, 0.5, 2.2, -0.4, VisionState.entering),
|
||||
(True, False, 2.0, 2.2, -0.8, VisionState.turning),
|
||||
(True, False, 1.2, 1.2, 0.3, VisionState.leaving),
|
||||
(True, True, 1.2, 1.2, 0.6, VisionState.overriding),
|
||||
)
|
||||
for long_enabled, override, current, predicted, planner_accel, state in cases:
|
||||
self.set_lat_accels(current, predicted)
|
||||
self.scc_v.update(self.sm, long_enabled, override, 20.0, planner_accel, 30.0)
|
||||
assert self.scc_v.state == state
|
||||
assert self.scc_v.output_a_target == planner_accel
|
||||
|
||||
def test_jitter_requires_confirmed_relief_then_releases_smoothly(self):
|
||||
self.enter_curve()
|
||||
previous_v_target = self.scc_v.output_v_target
|
||||
|
||||
for frame in range(_RELIEF_CONFIRMATION_FRAMES * 2):
|
||||
self.update_lat_accels(1.0, 1.05 if frame % 2 == 0 else 1.15)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert self.scc_v.output_v_target >= previous_v_target
|
||||
assert self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
|
||||
previous_v_target = self.scc_v.output_v_target
|
||||
|
||||
for _ in range(_RELIEF_CONFIRMATION_FRAMES):
|
||||
self.update_lat_accels(1.15, 0.8)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert 0.0 <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
|
||||
previous_v_target = self.scc_v.output_v_target
|
||||
|
||||
release_cruise = 30.0
|
||||
for _ in range(_RELIEF_CONFIRMATION_FRAMES - 1):
|
||||
self.update_lat_accels(0.8, 0.8, release_cruise)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert 0.0 <= self.scc_v.output_v_target - previous_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
|
||||
previous_v_target = self.scc_v.output_v_target
|
||||
|
||||
active_v_targets = [previous_v_target]
|
||||
for _ in range(int((release_cruise - previous_v_target) / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
|
||||
self.update_lat_accels(0.8, 0.8, release_cruise)
|
||||
if not self.scc_v.is_active:
|
||||
break
|
||||
assert self.scc_v.state == VisionState.leaving
|
||||
assert self.scc_v.output_v_target != V_CRUISE_UNSET
|
||||
active_v_targets.append(self.scc_v.output_v_target)
|
||||
|
||||
assert self.scc_v.state == VisionState.enabled
|
||||
assert self.scc_v.output_v_target == V_CRUISE_UNSET
|
||||
self.assert_approx(active_v_targets[-1], release_cruise)
|
||||
assert np.all((np.diff(active_v_targets) >= 0.0) & (np.diff(active_v_targets) <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9))
|
||||
|
||||
def test_target_release_waits_for_relief_above_ego_speed(self):
|
||||
self.enter_curve()
|
||||
held_v_target = self.scc_v.output_v_target
|
||||
self.assert_approx(held_v_target, self.scc_v.v_ego)
|
||||
|
||||
for _ in range(_RELIEF_CONFIRMATION_FRAMES + _TARGET_RELEASE_CONFIRMATION_FRAMES - 2):
|
||||
self.update_lat_accels(0.8, 0.8)
|
||||
self.assert_approx(self.scc_v.output_v_target, held_v_target)
|
||||
|
||||
self.update_lat_accels(0.8, 0.8)
|
||||
rise = self.scc_v.output_v_target - held_v_target
|
||||
assert 0.0 < rise <= _TARGET_RELEASE_RATE * DT_MDL + 1e-9
|
||||
|
||||
def test_curve_target_is_independent_of_ego_speed(self):
|
||||
model_speed = 24.0
|
||||
predicted_yaw_rate = 0.12
|
||||
predicted_lat_accel = model_speed * predicted_yaw_rate
|
||||
expected_v_target = (_A_LAT_REG_MAX / (predicted_yaw_rate / model_speed)) ** 0.5
|
||||
targets = []
|
||||
|
||||
for v_ego in (18.0, 28.0):
|
||||
controller = SmartCruiseControlVision()
|
||||
self.set_lat_accels(0.5, predicted_lat_accel, v_ego, model_speed)
|
||||
controller.update(self.sm, True, False, v_ego, 0.0, 30.0)
|
||||
controller.update(self.sm, True, False, v_ego, 0.0, 30.0)
|
||||
assert controller.state == VisionState.entering
|
||||
targets.append(controller.v_target)
|
||||
|
||||
self.assert_approx(targets[0], expected_v_target)
|
||||
self.assert_approx(targets[1], expected_v_target)
|
||||
|
||||
def test_curve_target_respects_minimum_speed_floor(self):
|
||||
model_speed = 10.0
|
||||
predicted_yaw_rate = 2.0
|
||||
self.set_lat_accels(0.5, model_speed * predicted_yaw_rate, model_speed=model_speed)
|
||||
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
|
||||
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
|
||||
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert self.scc_v.v_target < MIN_V
|
||||
self.assert_approx(self.scc_v.output_v_target, MIN_V)
|
||||
|
||||
@parameterized.expand(
|
||||
[([], []), ([np.nan] * len(ModelConstants.T_IDXS), [np.nan] * len(ModelConstants.T_IDXS)), ([20.0] * 5, [0.1] * 3)],
|
||||
names=["velocities", "yaw_rates"],
|
||||
)
|
||||
def test_model_vector_edges_remain_finite(self, velocities, yaw_rates):
|
||||
self.sm['modelV2'].velocity.x = velocities
|
||||
self.sm['modelV2'].orientationRate.z = yaw_rates
|
||||
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
|
||||
self.scc_v.update(self.sm, True, False, 20.0, 0.0, 30.0)
|
||||
|
||||
assert all(
|
||||
np.isfinite(value)
|
||||
for value in (
|
||||
self.scc_v.current_lat_acc,
|
||||
self.scc_v.max_pred_lat_acc,
|
||||
self.scc_v.v_target,
|
||||
self.scc_v.output_v_target,
|
||||
self.scc_v.output_a_target,
|
||||
)
|
||||
)
|
||||
|
||||
@parameterized.expand([(5.75,), (9.9,), (_MIN_ACTIVATION_SPEED,)])
|
||||
def test_vision_control_does_not_steal_launch(self, launch_speed):
|
||||
self.set_lat_accels(0.5, 3.0, launch_speed)
|
||||
self.scc_v.update(self.sm, True, False, launch_speed, 0.0, 30.0)
|
||||
self.scc_v.update(self.sm, True, False, launch_speed, 0.0, 30.0)
|
||||
|
||||
assert launch_speed <= _MIN_ACTIVATION_SPEED
|
||||
assert self.scc_v.state == VisionState.enabled
|
||||
assert not self.scc_v.is_active
|
||||
assert self.scc_v.output_v_target == V_CRUISE_UNSET
|
||||
|
||||
def test_vision_control_can_activate_above_launch_range(self):
|
||||
speed = _MIN_ACTIVATION_SPEED + 0.01
|
||||
self.set_lat_accels(0.5, 3.0, speed)
|
||||
self.scc_v.update(self.sm, True, False, speed, 0.0, 30.0)
|
||||
self.scc_v.update(self.sm, True, False, speed, 0.0, 30.0)
|
||||
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
assert self.scc_v.is_active
|
||||
|
||||
def test_nonurgent_activation_has_no_target_cliff(self):
|
||||
v_ego = _MIN_ACTIVATION_SPEED + 0.01
|
||||
model_speed = 8.0
|
||||
self.update_lat_accels(0.5, 2.0, v_ego=v_ego, model_speed=model_speed)
|
||||
self.update_lat_accels(0.5, 2.0, v_ego=v_ego, model_speed=model_speed)
|
||||
|
||||
self.assert_approx(self.scc_v.v_target, 8.0)
|
||||
self.assert_approx(self.scc_v.output_v_target, v_ego)
|
||||
|
||||
def test_nonurgent_tightening_is_confirmed_and_rate_limited(self):
|
||||
self.enter_curve()
|
||||
initial_v_target = self.scc_v.output_v_target
|
||||
|
||||
for _ in range(_TARGET_TIGHTEN_CONFIRMATION_FRAMES - 1):
|
||||
self.update_lat_accels(0.5, 2.8)
|
||||
self.assert_approx(self.scc_v.output_v_target, initial_v_target)
|
||||
|
||||
self.update_lat_accels(0.5, 2.8)
|
||||
drop = initial_v_target - self.scc_v.output_v_target
|
||||
assert 0.0 < drop <= _TARGET_TIGHTEN_RATE * DT_MDL + 1e-9
|
||||
|
||||
def test_one_frame_curve_prediction_does_not_pulse_target(self):
|
||||
self.enter_curve()
|
||||
for _ in range(10):
|
||||
self.update_lat_accels(0.5, 2.2)
|
||||
stable_v_target = self.scc_v.output_v_target
|
||||
|
||||
self.update_lat_accels(0.5, 2.8)
|
||||
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
|
||||
self.update_lat_accels(0.5, 2.2)
|
||||
|
||||
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
|
||||
|
||||
def test_one_frame_release_does_not_reverse_target(self):
|
||||
self.enter_curve(_URGENT_PRED_LAT_ACC_TH)
|
||||
stable_v_target = self.scc_v.output_v_target
|
||||
|
||||
self.update_lat_accels(0.5, 2.2)
|
||||
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
|
||||
self.update_lat_accels(0.5, _URGENT_PRED_LAT_ACC_TH)
|
||||
|
||||
self.assert_approx(self.scc_v.output_v_target, stable_v_target)
|
||||
|
||||
def test_urgent_predicted_curve_is_not_delayed(self):
|
||||
self.enter_curve()
|
||||
self.update_lat_accels(0.5, _URGENT_PRED_LAT_ACC_TH)
|
||||
|
||||
self.assert_approx(self.scc_v.output_v_target, self.scc_v._v_demand())
|
||||
|
||||
def test_current_curve_is_not_delayed(self):
|
||||
self.enter_curve()
|
||||
self.update_lat_accels(_TURNING_LAT_ACC_TH, 2.8)
|
||||
|
||||
self.assert_approx(self.scc_v.output_v_target, self.scc_v._v_demand())
|
||||
|
||||
def test_sequential_curve_confirms_release_and_tightens_urgently(self):
|
||||
self.enter_curve(3.0)
|
||||
for _ in range(20):
|
||||
self.update_lat_accels(0.5, 3.0)
|
||||
restrictive_v_target = self.scc_v.output_v_target
|
||||
|
||||
self.update_lat_accels(0.5, 1.4, a_ego=0.4)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
|
||||
assert self.scc_v.output_a_target == 0.4
|
||||
|
||||
for _ in range(_TARGET_RELEASE_CONFIRMATION_FRAMES - 2):
|
||||
self.update_lat_accels(0.5, 1.4)
|
||||
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
|
||||
|
||||
self.update_lat_accels(0.5, 1.4)
|
||||
released_v_target = self.scc_v.output_v_target
|
||||
assert 0.0 < released_v_target - restrictive_v_target <= _BELOW_EGO_TARGET_RELEASE_RATE * DT_MDL + 1e-9
|
||||
|
||||
self.update_lat_accels(0.5, 3.0, a_ego=-0.6)
|
||||
assert self.scc_v.state == VisionState.entering
|
||||
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
|
||||
assert self.scc_v.output_a_target == -0.6
|
||||
|
||||
for _ in range(4):
|
||||
self.update_lat_accels(0.5, 1.4)
|
||||
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
|
||||
self.update_lat_accels(0.5, 3.0)
|
||||
self.assert_approx(self.scc_v.output_v_target, restrictive_v_target)
|
||||
|
||||
def test_acceleration_is_continuous_through_planner_arbitration(self):
|
||||
car_control = messaging.new_message('carControl')
|
||||
car_control.carControl.enabled = True
|
||||
car_control.carControl.cruiseControl.override = False
|
||||
self.sm['carControl'] = car_control.carControl
|
||||
self.sm['carState'].vCruiseCluster = 108.0
|
||||
|
||||
planner: Any = LongitudinalPlannerSP.__new__(LongitudinalPlannerSP)
|
||||
planner.scc = SimpleNamespace(
|
||||
vision=self.scc_v,
|
||||
map=SimpleNamespace(output_v_target=V_CRUISE_UNSET, output_a_target=0.0),
|
||||
update=lambda sm, enabled, override, v_ego, a_ego, v_cruise: self.scc_v.update(sm, enabled, override, v_ego, a_ego, v_cruise),
|
||||
)
|
||||
planner.resolver = SimpleNamespace(
|
||||
speed_limit_valid=False,
|
||||
speed_limit_last_valid=False,
|
||||
speed_limit=0.0,
|
||||
speed_limit_final_last=0.0,
|
||||
distance=0.0,
|
||||
update=lambda _v_ego, _sm: None,
|
||||
)
|
||||
planner.sla = SimpleNamespace(
|
||||
output_v_target=V_CRUISE_UNSET,
|
||||
output_a_target=0.0,
|
||||
update=lambda *_args: None,
|
||||
)
|
||||
planner.events_sp = SimpleNamespace()
|
||||
|
||||
self.set_lat_accels(0.5, 2.2)
|
||||
planner.update_targets(self.sm, 20.0, -0.8, 30.0)
|
||||
planner.update_targets(self.sm, 20.0, -0.8, 30.0)
|
||||
assert planner.source == LongitudinalPlanSource.sccVision
|
||||
assert planner.output_a_target == -0.8
|
||||
|
||||
for planner_accel in (-2.0, 0.5, -0.2):
|
||||
planner.update_targets(self.sm, 20.0, planner_accel, 30.0)
|
||||
assert planner.source == LongitudinalPlanSource.sccVision
|
||||
assert planner.output_a_target == planner_accel
|
||||
|
||||
self.set_lat_accels(0.8, 0.8)
|
||||
for _ in range(int(30.0 / (_TARGET_RELEASE_RATE * DT_MDL)) + 10):
|
||||
planner.update_targets(self.sm, 20.0, 0.4, 30.0)
|
||||
assert planner.output_a_target == 0.4
|
||||
if planner.source == LongitudinalPlanSource.cruise:
|
||||
break
|
||||
else:
|
||||
self.fail("SCC Vision did not release to cruise")
|
||||
|
||||
planner.update_targets(self.sm, 20.0, 0.4, 30.0)
|
||||
assert self.scc_v.state == VisionState.enabled
|
||||
assert planner.source == LongitudinalPlanSource.cruise
|
||||
|
||||
@parameterized.expand(
|
||||
[
|
||||
@parameterized.expand([
|
||||
("p97_just_above_threshold", True),
|
||||
("single_spike_filtered", False),
|
||||
("persistent_high_values", True),
|
||||
],
|
||||
names=["case", "should_enter"],
|
||||
)
|
||||
], names=["case", "should_enter"])
|
||||
def test_max_pred_lat_acc_uses_p97_and_threshold(self, case, should_enter):
|
||||
n = len(ModelConstants.T_IDXS)
|
||||
th = float(_ENTERING_PRED_LAT_ACC_TH)
|
||||
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import gc
|
||||
from contextlib import ExitStack
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanSource
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.smart_cruise_control.vision_controller import _A_LAT_REG_MAX
|
||||
|
||||
|
||||
def _run_constant_curve(*, scc_enabled: bool, cruise: float, duration: float = 70.0) -> dict[str, np.ndarray]:
|
||||
gc.collect()
|
||||
curvature = 0.005
|
||||
plant = Plant(lead_relevancy=False, speed=30.0)
|
||||
planner = plant.planner
|
||||
planner.dec._enabled = False
|
||||
planner.scc.map.enabled = False
|
||||
planner.scc.vision.enabled = scc_enabled
|
||||
solver_failures = 0
|
||||
|
||||
with ExitStack() as patches:
|
||||
patches.enter_context(mock.patch.object(planner.dec, "_read_params", return_value=None))
|
||||
patches.enter_context(mock.patch.object(planner.scc.map, "update_params", return_value=None))
|
||||
patches.enter_context(mock.patch.object(planner.scc.vision, "_update_params", return_value=None))
|
||||
|
||||
original_mpc_reset = planner.mpc.reset
|
||||
|
||||
def record_mpc_reset(*args, **kwargs):
|
||||
nonlocal solver_failures
|
||||
solver_failures += int(planner.mpc.solution_status != 0)
|
||||
return original_mpc_reset(*args, **kwargs)
|
||||
|
||||
patches.enter_context(mock.patch.object(planner.mpc, "reset", side_effect=record_mpc_reset))
|
||||
|
||||
if scc_enabled:
|
||||
original_update_calculations = planner.scc.vision._update_calculations
|
||||
|
||||
def inject_constant_curvature(sm):
|
||||
velocities = np.asarray(sm['modelV2'].velocity.x, dtype=float)
|
||||
sm['modelV2'].orientationRate.z = (curvature * velocities).tolist()
|
||||
sm['controlsState'].curvature = curvature
|
||||
original_update_calculations(sm)
|
||||
|
||||
patches.enter_context(mock.patch.object(planner.scc.vision, "_update_calculations", side_effect=inject_constant_curvature))
|
||||
|
||||
original_update = planner.update
|
||||
|
||||
def enable_longitudinal(sm):
|
||||
sm['carControl'].enabled = True
|
||||
sm['carControl'].longActive = True
|
||||
original_update(sm)
|
||||
|
||||
patches.enter_context(mock.patch.object(planner, "update", side_effect=enable_longitudinal))
|
||||
rows = []
|
||||
while plant.current_time < duration:
|
||||
output = plant.step(v_cruise=cruise)
|
||||
rows.append(
|
||||
(
|
||||
plant.current_time,
|
||||
output['speed'],
|
||||
output['should_stop'],
|
||||
planner.scc.vision.is_active,
|
||||
planner.source == LongitudinalPlanSource.sccVision,
|
||||
planner.scc.vision.output_v_target,
|
||||
)
|
||||
)
|
||||
|
||||
data = np.asarray(rows, dtype=float)
|
||||
gc.collect()
|
||||
return {
|
||||
'time': data[:, 0],
|
||||
'speed': data[:, 1],
|
||||
'should_stop': data[:, 2],
|
||||
'active': data[:, 3],
|
||||
'scc_source': data[:, 4],
|
||||
'target': data[:, 5],
|
||||
'solver_failures': np.asarray(solver_failures),
|
||||
}
|
||||
|
||||
|
||||
class TestVisionControllerClosedLoop(OpenpilotTestCase):
|
||||
def test_constant_curve_recovers_like_stock_speed_cap(self):
|
||||
target = (_A_LAT_REG_MAX / 0.005) ** 0.5
|
||||
scc = _run_constant_curve(scc_enabled=True, cruise=30.0)
|
||||
stock = _run_constant_curve(scc_enabled=False, cruise=target)
|
||||
scc_final = scc['speed'][scc['time'] >= 60.0]
|
||||
stock_final = stock['speed'][stock['time'] >= 60.0]
|
||||
|
||||
# The generated solver can report platform-specific failures for the
|
||||
# synthetic no-lead plant. The feature must not make that stock baseline
|
||||
# worse; requiring an absolute zero would hide a harness difference as a
|
||||
# controller regression.
|
||||
assert scc['solver_failures'] <= stock['solver_failures']
|
||||
assert not scc['should_stop'].any()
|
||||
assert np.all(scc['active'][scc['time'] >= 60.0])
|
||||
assert np.all(scc['scc_source'][scc['time'] >= 60.0])
|
||||
assert np.allclose(scc['target'][scc['time'] >= 60.0], target)
|
||||
assert scc_final.min() >= target - 1.0
|
||||
assert abs(scc_final.mean() - stock_final.mean()) < 0.5
|
||||
assert abs(scc_final.min() - stock_final.min()) < 1.0
|
||||
assert abs(scc_final.max() - stock_final.max()) < 1.0
|
||||
+61
-89
@@ -23,21 +23,25 @@ _ENTERING_PRED_LAT_ACC_TH = 1.3 # Predicted Lat Acc threshold to trigger enteri
|
||||
_ABORT_ENTERING_PRED_LAT_ACC_TH = 1.1 # Predicted Lat Acc threshold to abort entering state if speed drops.
|
||||
|
||||
_TURNING_LAT_ACC_TH = 1.6 # Lat Acc threshold to trigger turning state.
|
||||
_URGENT_PRED_LAT_ACC_TH = 3. # Predicted Lat Acc threshold that requires an immediate speed reduction.
|
||||
|
||||
_LEAVING_LAT_ACC_TH = 1.3 # Lat Acc threshold to trigger leaving turn state.
|
||||
_FINISH_LAT_ACC_TH = 1.1 # Lat Acc threshold to trigger the end of the turn cycle.
|
||||
|
||||
_A_LAT_REG_MAX = 2. # Maximum lateral acceleration
|
||||
|
||||
_RELIEF_CONFIRMATION_FRAMES = max(1, int(round(0.5 / DT_MDL)))
|
||||
_TARGET_TIGHTEN_CONFIRMATION_FRAMES = max(1, int(round(0.1 / DT_MDL)))
|
||||
_TARGET_RELEASE_CONFIRMATION_FRAMES = max(1, int(round(0.15 / DT_MDL)))
|
||||
_TARGET_TIGHTEN_RATE = 5. # m/s^2
|
||||
_TARGET_RELEASE_RATE = 1. # m/s^2
|
||||
_BELOW_EGO_TARGET_RELEASE_RATE = 3. # m/s^2
|
||||
_MIN_PRED_SPEED = 1. # m/s
|
||||
_MIN_ACTIVATION_SPEED = 10. # m/s
|
||||
_NO_OVERSHOOT_TIME_HORIZON = 4. # s. Time to use for velocity desired based on a_target when not overshooting.
|
||||
|
||||
# Lookup table for the minimum smooth deceleration during the ENTERING state
|
||||
# depending on the actual maximum absolute lateral acceleration predicted on the turn ahead.
|
||||
_ENTERING_SMOOTH_DECEL_V = [-0.2, -1.] # min decel value allowed on ENTERING state
|
||||
_ENTERING_SMOOTH_DECEL_BP = [1.3, 3.] # absolute value of lat acc ahead
|
||||
|
||||
# Lookup table for the acceleration for the TURNING state
|
||||
# depending on the current lateral acceleration of the vehicle.
|
||||
_TURNING_ACC_V = [0.5, 0., -0.4] # acc value
|
||||
_TURNING_ACC_BP = [1.5, 2.3, 3.] # absolute value of current lat acc
|
||||
|
||||
_LEAVING_ACC = 0.5 # Conformable acceleration to regain speed while leaving a turn.
|
||||
|
||||
|
||||
class SmartCruiseControlVision:
|
||||
@@ -61,62 +65,14 @@ class SmartCruiseControlVision:
|
||||
self.state = VisionState.disabled
|
||||
self.current_lat_acc = 0.
|
||||
self.max_pred_lat_acc = 0.
|
||||
self.relief_frames = 0
|
||||
self.tighten_frames = 0
|
||||
self.release_frames = 0
|
||||
|
||||
def _v_demand(self) -> float:
|
||||
return max(MIN_V, min(self.v_target, self.v_cruise_setpoint))
|
||||
|
||||
def _curve_is_urgent(self) -> bool:
|
||||
return self.current_lat_acc >= _TURNING_LAT_ACC_TH or self.max_pred_lat_acc >= _URGENT_PRED_LAT_ACC_TH
|
||||
|
||||
def _filtered_v_target(self) -> float:
|
||||
demand = self._v_demand()
|
||||
|
||||
if self.output_v_target == V_CRUISE_UNSET:
|
||||
self.tighten_frames = 0
|
||||
self.release_frames = 0
|
||||
if self._curve_is_urgent():
|
||||
return demand
|
||||
return max(demand, min(self.v_ego, self.v_cruise_setpoint))
|
||||
|
||||
if demand < self.output_v_target:
|
||||
self.release_frames = 0
|
||||
if self._curve_is_urgent():
|
||||
self.tighten_frames = 0
|
||||
return demand
|
||||
|
||||
self.tighten_frames += 1
|
||||
if self.tighten_frames < _TARGET_TIGHTEN_CONFIRMATION_FRAMES:
|
||||
return self.output_v_target
|
||||
return max(demand, self.output_v_target - _TARGET_TIGHTEN_RATE * DT_MDL)
|
||||
|
||||
self.tighten_frames = 0
|
||||
releasing_brake = self.output_v_target < min(self.v_ego, demand)
|
||||
if not releasing_brake and self.relief_frames < _RELIEF_CONFIRMATION_FRAMES:
|
||||
self.release_frames = 0
|
||||
return self.output_v_target
|
||||
|
||||
if demand > self.output_v_target:
|
||||
self.release_frames += 1
|
||||
if self.release_frames < _TARGET_RELEASE_CONFIRMATION_FRAMES:
|
||||
return self.output_v_target
|
||||
else:
|
||||
self.release_frames = 0
|
||||
|
||||
release_rate = _BELOW_EGO_TARGET_RELEASE_RATE if releasing_brake else _TARGET_RELEASE_RATE
|
||||
return min(demand, self.output_v_target + release_rate * DT_MDL)
|
||||
|
||||
def get_a_target_from_control(self) -> float:
|
||||
return self.a_ego
|
||||
return self.a_target
|
||||
|
||||
def get_v_target_from_control(self) -> float:
|
||||
if self.is_active:
|
||||
return self._filtered_v_target()
|
||||
return max(self.v_target, MIN_V) + self.a_target * _NO_OVERSHOOT_TIME_HORIZON
|
||||
|
||||
self.tighten_frames = 0
|
||||
self.release_frames = 0
|
||||
return V_CRUISE_UNSET
|
||||
|
||||
def _update_params(self) -> None:
|
||||
@@ -126,27 +82,25 @@ class SmartCruiseControlVision:
|
||||
def _update_calculations(self, sm: messaging.SubMaster) -> None:
|
||||
if not self.long_enabled:
|
||||
return
|
||||
else:
|
||||
rate_plan = np.array(np.abs(sm['modelV2'].orientationRate.z))
|
||||
vel_plan = np.array(sm['modelV2'].velocity.x)
|
||||
|
||||
rate_plan = np.asarray(np.abs(sm['modelV2'].orientationRate.z), dtype=float)
|
||||
vel_plan = np.asarray(sm['modelV2'].velocity.x, dtype=float)
|
||||
size = min(len(rate_plan), len(vel_plan))
|
||||
rate_plan, vel_plan = rate_plan[:size], vel_plan[:size]
|
||||
valid = np.isfinite(rate_plan) & np.isfinite(vel_plan) & (vel_plan >= _MIN_PRED_SPEED)
|
||||
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
|
||||
|
||||
self.current_lat_acc = self.v_ego ** 2 * abs(sm['controlsState'].curvature)
|
||||
self.max_pred_lat_acc = 0.
|
||||
self.v_target = V_CRUISE_UNSET
|
||||
if np.any(valid):
|
||||
self.max_pred_lat_acc = float(np.percentile(rate_plan[valid] * vel_plan[valid], 97))
|
||||
max_pred_curvature = float(np.percentile(rate_plan[valid] / vel_plan[valid], 97))
|
||||
if max_pred_curvature > 0.:
|
||||
self.v_target = min(float((_A_LAT_REG_MAX / max_pred_curvature) ** 0.5), V_CRUISE_UNSET)
|
||||
# get the maximum lat accel from the model
|
||||
predicted_lat_accels = rate_plan * vel_plan
|
||||
self.max_pred_lat_acc = np.percentile(predicted_lat_accels, 97)
|
||||
|
||||
# get the maximum curve based on the current velocity
|
||||
v_ego = max(self.v_ego, 0.1) # ensure a value greater than 0 for calculations
|
||||
max_curve = self.max_pred_lat_acc / (v_ego**2)
|
||||
|
||||
# Get the target velocity for the maximum curve
|
||||
self.v_target = (_A_LAT_REG_MAX / max_curve) ** 0.5
|
||||
|
||||
def _update_state_machine(self) -> tuple[bool, bool]:
|
||||
# ENABLED, ENTERING, TURNING, LEAVING, OVERRIDING
|
||||
relief = self.current_lat_acc < _FINISH_LAT_ACC_TH and self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH
|
||||
self.relief_frames = self.relief_frames + 1 if self.state in ACTIVE_STATES and relief else 0
|
||||
|
||||
if self.state != VisionState.disabled:
|
||||
# longitudinal and feature disable always have priority in a non-disabled state
|
||||
if not self.long_enabled or not self.enabled:
|
||||
@@ -158,7 +112,7 @@ class SmartCruiseControlVision:
|
||||
# ENABLED
|
||||
if self.state == VisionState.enabled:
|
||||
# Do not enter a turn control cycle if the speed is low.
|
||||
if self.v_ego <= _MIN_ACTIVATION_SPEED:
|
||||
if self.v_ego <= MIN_V:
|
||||
pass
|
||||
# If significant lateral acceleration is predicted ahead, then move to Entering turn state.
|
||||
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
|
||||
@@ -174,26 +128,23 @@ class SmartCruiseControlVision:
|
||||
# Transition to Turning if current lateral acceleration is over the threshold.
|
||||
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
|
||||
self.state = VisionState.turning
|
||||
# Begin releasing only after both current and predicted lateral acceleration stay clear.
|
||||
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES:
|
||||
self.state = VisionState.leaving
|
||||
# Abort if the predicted lateral acceleration drops
|
||||
elif self.max_pred_lat_acc < _ABORT_ENTERING_PRED_LAT_ACC_TH:
|
||||
self.state = VisionState.enabled
|
||||
|
||||
# TURNING
|
||||
elif self.state == VisionState.turning:
|
||||
# Transition out of Turning if current lateral acceleration drops below a threshold.
|
||||
# Transition to Leaving if current lateral acceleration drops below a threshold.
|
||||
if self.current_lat_acc <= _LEAVING_LAT_ACC_TH:
|
||||
self.state = VisionState.entering if self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH else VisionState.leaving
|
||||
self.state = VisionState.leaving
|
||||
|
||||
# LEAVING
|
||||
elif self.state == VisionState.leaving:
|
||||
# Transition back to Turning if current lateral acceleration goes back over the threshold.
|
||||
if self.current_lat_acc >= _TURNING_LAT_ACC_TH:
|
||||
self.state = VisionState.turning
|
||||
# Start a new turn cycle immediately if another curve is predicted.
|
||||
elif self.max_pred_lat_acc >= _ENTERING_PRED_LAT_ACC_TH:
|
||||
self.state = VisionState.entering
|
||||
# Finish after confirmed relief and a gradual release to the cruise setpoint.
|
||||
elif self.relief_frames >= _RELIEF_CONFIRMATION_FRAMES and self.output_v_target >= self.v_cruise_setpoint:
|
||||
# Finish if current lateral acceleration goes below a threshold.
|
||||
elif self.current_lat_acc < _FINISH_LAT_ACC_TH:
|
||||
self.state = VisionState.enabled
|
||||
|
||||
# DISABLED
|
||||
@@ -206,11 +157,32 @@ class SmartCruiseControlVision:
|
||||
|
||||
enabled = self.state in ENABLED_STATES
|
||||
active = self.state in ACTIVE_STATES
|
||||
if not active:
|
||||
self.relief_frames = 0
|
||||
|
||||
return enabled, active
|
||||
|
||||
def _update_solution(self) -> float:
|
||||
# DISABLED, ENABLED, OVERRIDING
|
||||
if self.state not in ACTIVE_STATES:
|
||||
# when not overshooting, calculate v_turn as the speed at the prediction horizon when following
|
||||
# the smooth deceleration.
|
||||
a_target = self.a_ego
|
||||
# ENTERING
|
||||
elif self.state == VisionState.entering:
|
||||
# when not overshooting, target a smooth deceleration in preparation for a sharp turn to come.
|
||||
a_target = np.interp(self.max_pred_lat_acc, _ENTERING_SMOOTH_DECEL_BP, _ENTERING_SMOOTH_DECEL_V)
|
||||
# TURNING
|
||||
elif self.state == VisionState.turning:
|
||||
# When turning, we provide a target acceleration that is comfortable for the lateral acceleration felt.
|
||||
a_target = np.interp(self.current_lat_acc, _TURNING_ACC_BP, _TURNING_ACC_V)
|
||||
# LEAVING
|
||||
elif self.state == VisionState.leaving:
|
||||
# When leaving, we provide a comfortable acceleration to regain speed.
|
||||
a_target = _LEAVING_ACC
|
||||
else:
|
||||
raise NotImplementedError(f"SCC-V state not supported: {self.state}")
|
||||
|
||||
return a_target
|
||||
|
||||
def update(self, sm: messaging.SubMaster, long_enabled: bool, long_override: bool, v_ego: float, a_ego: float,
|
||||
v_cruise_setpoint: float) -> None:
|
||||
self.long_enabled = long_enabled
|
||||
@@ -223,7 +195,7 @@ class SmartCruiseControlVision:
|
||||
self._update_calculations(sm)
|
||||
|
||||
self.is_enabled, self.is_active = self._update_state_machine()
|
||||
self.a_target = self.a_ego
|
||||
self.a_target = self._update_solution()
|
||||
|
||||
self.output_v_target = self.get_v_target_from_control()
|
||||
self.output_a_target = self.get_a_target_from_control()
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
|
||||
|
||||
|
||||
STOP_LEVEL_V = 0.4 # m/s
|
||||
STOP_LEVEL_TAU = 0.2 # s
|
||||
STOP_CONFIRM_RISE = 0.2 # m/s^2
|
||||
STOP_CONFIRM_DELAY = 0.2 # s
|
||||
|
||||
|
||||
class StopConfirm:
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.level: float | None = None
|
||||
self.collapsed = False
|
||||
self.confirmed_t = -1.0
|
||||
|
||||
def update(self, standstill: bool, v_ego: float, a_long: float | None) -> bool:
|
||||
"""returns True once the stop is confirmed and STOP_CONFIRM_DELAY has passed"""
|
||||
if a_long is None or not math.isfinite(a_long):
|
||||
self.reset()
|
||||
return False
|
||||
if v_ego > STOP_LEVEL_V and not standstill:
|
||||
alpha = DT_CTRL / (STOP_LEVEL_TAU + DT_CTRL)
|
||||
self.level = a_long if self.level is None else self.level + alpha * (a_long - self.level)
|
||||
self.collapsed = False
|
||||
self.confirmed_t = -1.0
|
||||
return False
|
||||
if self.level is not None and a_long >= self.level + STOP_CONFIRM_RISE:
|
||||
self.collapsed = True
|
||||
if standstill and self.collapsed:
|
||||
self.confirmed_t = 0.0 if self.confirmed_t < 0.0 else self.confirmed_t + DT_CTRL
|
||||
elif not standstill:
|
||||
self.confirmed_t = -1.0
|
||||
return self.confirmed_t >= STOP_CONFIRM_DELAY - 1e-9
|
||||
|
||||
|
||||
class StoppingController:
|
||||
"""Optional terminal-stop policy applied after stock LongControl.update()."""
|
||||
|
||||
STOPPING_DECEL_RATE = 0.3 # m/s^2/s
|
||||
STANDSTILL_HOLD_RATE = 1.0 # m/s^2/s
|
||||
STANDSTILL_HOLD_DELAY_LEAD = 1.0 # s
|
||||
STANDSTILL_HOLD_DELAY_NO_LEAD = 1.0 # s
|
||||
STOPPING_FREEZE_MAX = 2.0 # s
|
||||
STOPPING_EXIT_DEBOUNCE = 0.2 # s
|
||||
ENGAGE_STANDSTILL_GRACE = 0.6 # s
|
||||
STOPPING_FOLLOW_MIN = -0.10 # m/s^2
|
||||
STOPPING_FOLLOW_RATE = 2.0 # m/s^3
|
||||
CREEP_V_MIN = 0.03 # m/s
|
||||
CREEP_RATE_GROWTH = 1.5 # m/s^2/s
|
||||
CREEP_RATE_MAX = 1.0 # m/s^2/s
|
||||
|
||||
BLEND_V = 10.0 / 3.6 # m/s:
|
||||
END_V_HI = 5.0 / 3.6 # m/s
|
||||
END_HI = -0.65 # m/s^2
|
||||
END_LO = -0.35 # m/s^2
|
||||
END_CURVE_V = [0.0, 1.0 / 3.6, 2.0 / 3.6, 3.0 / 3.6, 4.0 / 3.6, 5.0 / 3.6] # m/s
|
||||
END_CURVE_A = [END_LO, (END_LO + -0.56) / 2, -0.56, -0.60, -0.65, END_HI] # m/s^2
|
||||
END_PLAN_MIN = -0.25 # m/s^2
|
||||
END_RATE = 2.0 # m/s^3
|
||||
END_CAP_RATE = 1.0 # m/s^3
|
||||
|
||||
RELEASE_FAST = 4.0 # m/s^3
|
||||
RELEASE_SLOW = 1.5 # m/s^3
|
||||
RELEASE_BAND_LO = -0.6 # m/s^2
|
||||
RELEASE_BAND_HI = 0.4 # m/s^2
|
||||
|
||||
LAUNCH_RATE = 1.0 # m/s^3
|
||||
REROLL_ACCEL = -0.6 # m/s^2
|
||||
REROLL_JERK = 6.0 # m/s^3
|
||||
|
||||
FLAT_HOLD_ACCEL = -1.2 # m/s^2
|
||||
FLAT_PITCH_DEG = 1.0 # deg
|
||||
SLOPE_PITCH_DEG = 2.0 # deg
|
||||
CF_WINDOW = 10.0 # s
|
||||
CF_V_MAX = 8.0 / 3.6 # m/s
|
||||
CF_TRIGGER = -0.15 # m/s^2
|
||||
CF_END = -0.24 # m/s^2
|
||||
CF_PLAN_SOFT = -0.45 # m/s^2
|
||||
CF_PLAN_FIRM = -0.70 # m/s^2
|
||||
CF_RATE = 0.9 # m/s^3
|
||||
|
||||
CF_SETTLE_ACCEL = -0.6 # m/s^2
|
||||
CF_SETTLE_JERK = 0.72 # m/s^3
|
||||
CF_HOLD_RATE = 0.3 # m/s^2/s
|
||||
|
||||
def __init__(self, stop_accel):
|
||||
self.stop_accel = stop_accel
|
||||
self.stop_confirm = StopConfirm()
|
||||
self.cf_t: float | None = None
|
||||
self.cf_vmax = 0.0
|
||||
self.cf_stopping = False
|
||||
self.cf_stopped = False
|
||||
self.standstill_t = 0.0
|
||||
self.stopping_t = 0.0
|
||||
self.go_t = 0.0
|
||||
self.engaged_t = 0.0
|
||||
self.stopped_once = False
|
||||
self.creep_t = 0.0
|
||||
self.end_active = False
|
||||
self.a_entry = self.END_HI
|
||||
self.releasing = False
|
||||
|
||||
def _blend(self, v_ego):
|
||||
if v_ego < self.END_V_HI:
|
||||
return float(np.interp(max(v_ego, 0.0), self.END_CURVE_V, self.END_CURVE_A))
|
||||
return float(np.interp(v_ego, [self.END_V_HI, self.BLEND_V], [self.END_HI, self.a_entry]))
|
||||
|
||||
def _end_request(self, a_target, prev_accel, v_ego, has_lead=True):
|
||||
line = self._blend(v_ego)
|
||||
target = min(a_target, line) # the firmer of the plan and the line
|
||||
if not has_lead and v_ego < self.END_V_HI and target < line:
|
||||
return float(min(line, prev_accel + self.END_CAP_RATE * DT_CTRL))
|
||||
step = self.END_RATE * DT_CTRL
|
||||
return float(np.clip(target, prev_accel - step, prev_accel + step))
|
||||
|
||||
def _hold_floor(self, pitch, creeping):
|
||||
if creeping or pitch is None or not math.isfinite(pitch):
|
||||
return self.stop_accel
|
||||
if abs(math.degrees(pitch) - self.FLAT_PITCH_DEG) >= self.SLOPE_PITCH_DEG:
|
||||
return self.stop_accel
|
||||
return max(self.stop_accel, self.FLAT_HOLD_ACCEL)
|
||||
|
||||
def _cf_request(self, a_target, prev_accel):
|
||||
target = float(np.interp(a_target, [self.CF_PLAN_FIRM, self.CF_PLAN_SOFT], [self.CF_PLAN_FIRM, self.CF_END]))
|
||||
target = min(target, a_target) if a_target < self.CF_PLAN_FIRM else target
|
||||
if target < prev_accel:
|
||||
return max(target, min(prev_accel, 0.0) - self.CF_RATE * DT_CTRL)
|
||||
return min(target, prev_accel + self.END_RATE * DT_CTRL)
|
||||
|
||||
def _update_creep_follow(self, state, CS, a_target):
|
||||
if state == LongCtrlState.off:
|
||||
self.cf_t, self.cf_stopping, self.cf_stopped = None, False, False
|
||||
return False
|
||||
if CS.standstill:
|
||||
if self.cf_stopping:
|
||||
self.cf_stopped = True
|
||||
self.cf_t = 0.0 if self.standstill_t > 0.5 else self.cf_t
|
||||
self.cf_vmax, self.cf_stopping = 0.0, False
|
||||
return False
|
||||
self.cf_stopped = False
|
||||
if self.cf_t is None:
|
||||
return False
|
||||
self.cf_t += DT_CTRL
|
||||
self.cf_vmax = max(self.cf_vmax, CS.vEgo)
|
||||
if self.cf_t > self.CF_WINDOW or self.cf_vmax >= self.CF_V_MAX or a_target > 0.0 and self.cf_stopping:
|
||||
self.cf_t, self.cf_stopping = None, False
|
||||
return False
|
||||
if a_target <= self.CF_TRIGGER:
|
||||
self.cf_stopping = True
|
||||
return self.cf_stopping
|
||||
|
||||
def update(self, prev_state, state, CS, a_target, prev_accel, stock_accel, accel_limits, has_lead=False, pitch=None,
|
||||
a_long=None):
|
||||
if prev_state == LongCtrlState.stopping and state == LongCtrlState.pid and CS.standstill:
|
||||
self.go_t += DT_CTRL
|
||||
if self.go_t < self.STOPPING_EXIT_DEBOUNCE:
|
||||
state = LongCtrlState.stopping
|
||||
else:
|
||||
self.go_t = 0.0
|
||||
|
||||
self.standstill_t = self.standstill_t + DT_CTRL if CS.standstill else 0.0
|
||||
self.engaged_t = 0.0 if state == LongCtrlState.off else self.engaged_t + DT_CTRL
|
||||
self.stopping_t = self.stopping_t + DT_CTRL if state == LongCtrlState.stopping else 0.0
|
||||
if state != LongCtrlState.stopping:
|
||||
self.stopped_once = False
|
||||
elif CS.standstill:
|
||||
self.stopped_once = True
|
||||
|
||||
creeping = self.stopped_once and not CS.standstill and CS.vEgo > self.CREEP_V_MIN
|
||||
self.creep_t = self.creep_t + DT_CTRL if creeping else 0.0
|
||||
cf_stop = self._update_creep_follow(state, CS, a_target)
|
||||
stop_confirmed = self.stop_confirm.update(CS.standstill, CS.vEgo, a_long)
|
||||
|
||||
rolling = not CS.standstill and not self.stopped_once
|
||||
if state == LongCtrlState.off or not rolling or CS.vEgo >= self.BLEND_V or a_target > 0.0:
|
||||
self.end_active = False
|
||||
elif not self.end_active and a_target <= self.END_PLAN_MIN:
|
||||
self.end_active = True
|
||||
self.a_entry = min(prev_accel, self.END_HI)
|
||||
|
||||
if state != LongCtrlState.stopping:
|
||||
if cf_stop:
|
||||
return state, float(np.clip(self._cf_request(a_target, prev_accel), accel_limits[0], accel_limits[1]))
|
||||
if self.end_active:
|
||||
return state, float(np.clip(self._end_request(a_target, prev_accel, CS.vEgo, has_lead), accel_limits[0], accel_limits[1]))
|
||||
if prev_state == LongCtrlState.stopping and state == LongCtrlState.pid and prev_accel < self.RELEASE_BAND_HI:
|
||||
self.releasing = True
|
||||
if self.releasing:
|
||||
if stock_accel <= prev_accel or state == LongCtrlState.off:
|
||||
self.releasing = False
|
||||
return state, stock_accel
|
||||
if prev_accel < self.RELEASE_BAND_LO:
|
||||
rate = self.RELEASE_FAST
|
||||
elif prev_accel < self.RELEASE_BAND_HI:
|
||||
rate = self.RELEASE_SLOW
|
||||
else:
|
||||
rate = self.LAUNCH_RATE
|
||||
return state, float(min(stock_accel, prev_accel + rate * DT_CTRL))
|
||||
return state, stock_accel
|
||||
|
||||
output_accel = prev_accel
|
||||
hold_floor = self._hold_floor(pitch, creeping)
|
||||
if output_accel > hold_floor:
|
||||
output_accel = min(output_accel, 0.0)
|
||||
if not CS.standstill and not self.stopped_once and a_target < self.STOPPING_FOLLOW_MIN and a_target > output_accel:
|
||||
output_accel = min(a_target, output_accel + self.STOPPING_FOLLOW_RATE * DT_CTRL)
|
||||
if cf_stop:
|
||||
output_accel = self._cf_request(a_target, prev_accel)
|
||||
elif self.end_active:
|
||||
output_accel = self._end_request(a_target, prev_accel, CS.vEgo, has_lead)
|
||||
|
||||
hold_delay = self.STANDSTILL_HOLD_DELAY_LEAD if has_lead else self.STANDSTILL_HOLD_DELAY_NO_LEAD
|
||||
if CS.standstill and not has_lead and self.engaged_t < self.ENGAGE_STANDSTILL_GRACE:
|
||||
rate = 0.0
|
||||
elif self.cf_stopped and CS.standstill and output_accel > self.CF_SETTLE_ACCEL:
|
||||
output_accel = max(self.CF_SETTLE_ACCEL, output_accel - self.CF_SETTLE_JERK * DT_CTRL)
|
||||
rate = 0.0
|
||||
elif self.cf_stopped and CS.standstill:
|
||||
rate = self.CF_HOLD_RATE # H: then the rest of the hold, slowly
|
||||
elif self.standstill_t >= hold_delay or stop_confirmed:
|
||||
rate = self.STANDSTILL_HOLD_RATE
|
||||
elif creeping and output_accel > self.REROLL_ACCEL:
|
||||
output_accel = max(self.REROLL_ACCEL, output_accel - self.REROLL_JERK * DT_CTRL)
|
||||
rate = 0.0
|
||||
elif creeping:
|
||||
rate = min(self.STOPPING_DECEL_RATE + self.CREEP_RATE_GROWTH * self.creep_t, self.CREEP_RATE_MAX)
|
||||
elif self.stopping_t >= self.STOPPING_FREEZE_MAX:
|
||||
rate = self.STOPPING_DECEL_RATE
|
||||
else:
|
||||
rate = 0.0
|
||||
if hold_floor != self.stop_accel:
|
||||
output_accel = max(output_accel - rate * DT_CTRL, min(hold_floor, output_accel))
|
||||
else:
|
||||
output_accel -= rate * DT_CTRL
|
||||
|
||||
return state, float(np.clip(output_accel, accel_limits[0], accel_limits[1]))
|
||||
@@ -8,7 +8,7 @@ from openpilot.common.parameterized import parameterized
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.controls.lib.desire_helper import DesireHelper, LaneChangeState, LaneChangeDirection
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.auto_lane_change import AutoLaneChangeController, AutoLaneChangeMode, \
|
||||
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY, EDGE_DELAY
|
||||
AUTO_LANE_CHANGE_TIMER, ONE_SECOND_DELAY
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
AUTO_LANE_CHANGE_TIMER_COMBOS = [
|
||||
@@ -61,7 +61,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Update controller
|
||||
num_updates = int(5.0 / DT_MDL)
|
||||
for _ in range(num_updates): # Run for 5 seconds
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Mode should not allow lane change immediately
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -74,11 +74,11 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
|
||||
# Update controller once to read params
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Update multiple times to exceed the timer threshold
|
||||
for _ in range(1): # Should exceed 0.1s with multiple DT_MDL updates
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -91,7 +91,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update controller once
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# The timer should still be below the threshold after one update
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -99,7 +99,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -116,7 +116,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update with brake pressed for 1 second
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=True)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=True)
|
||||
|
||||
# Even though it is an auto lane change mode, lane change should be disallowed due to brake pressed prior initiating lane change
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
@@ -126,7 +126,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Even releasing brake shouldn't allow auto lane change
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@@ -139,13 +139,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with blindspot detected - this should prevent auto lane change
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Keep updating with blindspot detected - should still prevent auto lane change
|
||||
num_updates = int(timer_delay / DT_MDL) + 1 # Add one extra updates to ensure we exceed the threshold
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
@@ -157,13 +157,13 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# First update with blindspot detected to set the negative timer
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Now update with blindspot cleared - should start incrementing timer from negative value
|
||||
num_updates = int((timer_delay + abs(ONE_SECOND_DELAY)) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# After sufficient updates with no blindspot, auto lane change should be allowed
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
@@ -177,7 +177,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Now lane change should be allowed
|
||||
assert self.alc.lane_change_wait_timer > self.alc.lane_change_delay
|
||||
@@ -193,7 +193,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
|
||||
# Update enough times to exceed the threshold (seconds / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@@ -206,86 +206,7 @@ class TestAutoLaneChangeController(OpenpilotTestCase):
|
||||
# Simulate updates for a long period of time (e.g., 10 seconds)
|
||||
num_updates = int(10.0 / DT_MDL)
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
self.alc.update_lane_change(blindspot_detected=False, brake_pressed=False)
|
||||
|
||||
# Lane change should never be allowed
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_detected_delays_auto_lane_change(self, timer_state, timer_delay):
|
||||
"""Test that road edge detection adds delay to auto lane change."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = False
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with edge detected - should prevent auto lane change
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Keep updating with edge detected - timer keeps resetting, should stay disallowed
|
||||
num_updates = int(timer_delay / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_detected_then_cleared_allows_after_delay(self, timer_state, timer_delay):
|
||||
"""Test that auto lane change is allowed after edge clears plus extra delay."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = False
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# First update with edge detected to set the negative timer
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Now update with edge cleared - should start incrementing timer from negative value
|
||||
num_updates = int((timer_delay + abs(EDGE_DELAY)) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
# After sufficient updates with no edge, auto lane change should be allowed
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_edge_and_bsm_combined_uses_worst_delay(self, timer_state, timer_delay):
|
||||
"""Test that when both BSM and edge are detected, the worst (longest) delay wins."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_bsm_delay = True
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Update with both blindspot and edge detected
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=True, brake_pressed=False)
|
||||
assert not self.alc.auto_lane_change_allowed
|
||||
|
||||
# Clear both - need to wait out the worst delay
|
||||
worst_delay = max(abs(ONE_SECOND_DELAY), abs(EDGE_DELAY))
|
||||
num_updates = int((timer_delay + worst_delay) / DT_MDL) + 1
|
||||
for _ in range(num_updates):
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=False, brake_pressed=False)
|
||||
|
||||
assert self.alc.auto_lane_change_allowed
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_nudge_overrides_edge_in_lane_change_blocked(self, timer_state, timer_delay):
|
||||
"""Test that lane_change_blocked is False when nudge applied with edge-only."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# Edge detected, no nudge - should be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=False)
|
||||
assert self.alc.lane_change_blocked
|
||||
|
||||
# Edge detected, nudge applied - should NOT be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=False, edge_detected=True, brake_pressed=False, torque_applied=True)
|
||||
assert not self.alc.lane_change_blocked
|
||||
|
||||
@parameterized.expand(AUTO_LANE_CHANGE_TIMER_COMBOS)
|
||||
def test_nudge_does_not_override_bsm_in_lane_change_blocked(self, timer_state, timer_delay):
|
||||
"""Test that lane_change_blocked stays True when BSM detected even with nudge."""
|
||||
self._reset_states()
|
||||
self.alc.lane_change_set_timer = timer_state
|
||||
|
||||
# BSM detected with nudge - should still be blocked
|
||||
self.alc.update_lane_change(blindspot_detected=True, edge_detected=False, brake_pressed=False, torque_applied=True)
|
||||
assert self.alc.lane_change_blocked
|
||||
|
||||
@@ -113,56 +113,12 @@ class TestDesireHelperIntegration(OpenpilotTestCase):
|
||||
left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.desire == expected_desire
|
||||
|
||||
def test_nudge_overrides_edge_detection(self, set_lane_turn_params):
|
||||
"""Manual steering nudge should override road edge detection and allow lane change."""
|
||||
def test_edge_blocks_lane_change(self, set_lane_turn_params):
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True, steeringPressed=True, steeringTorque=1)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.lane_change_direction == LaneChangeDirection.left
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
def test_nudge_does_not_override_bsm(self, set_lane_turn_params):
|
||||
"""Manual steering nudge must NOT override BSM blindspot detection."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGE
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True, leftBlindspot=True, steeringPressed=True, steeringTorque=1)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.desire == log.Desire.none
|
||||
|
||||
def test_edge_blocks_auto_lane_change(self, set_lane_turn_params):
|
||||
"""Road edge detection should still block auto (nudgeless) lane changes."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=True, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.preLaneChange
|
||||
assert dh.desire == log.Desire.none
|
||||
|
||||
def test_auto_lane_change_allowed_without_edge(self, set_lane_turn_params):
|
||||
"""Auto lane change should proceed when no edge and no BSM detected."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=False)
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
def test_right_edge_does_not_block_left_lane_change(self, set_lane_turn_params):
|
||||
"""Edge on opposite side should not affect lane change direction."""
|
||||
dh = DesireHelper()
|
||||
dh.alc.lane_change_set_timer = AutoLaneChangeMode.NUDGELESS
|
||||
dh.alc.param_read_counter = 1
|
||||
carstate = DummyCarState(vEgo=15, leftBlinker=True)
|
||||
for _ in range(10):
|
||||
dh.update(carstate, True, 1.0, left_edge_detected=False, right_edge_detected=True)
|
||||
assert dh.lane_change_state == LaneChangeState.laneChangeStarting
|
||||
assert dh.desire == log.Desire.laneChangeLeft
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import math
|
||||
from types import SimpleNamespace
|
||||
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.stopping_controller import StoppingController
|
||||
|
||||
LIMITS = (-3.5, 2.0)
|
||||
PID, STOPPING, OFF = LongCtrlState.pid, LongCtrlState.stopping, LongCtrlState.off
|
||||
END_LO, END_HI = StoppingController.END_LO, StoppingController.END_HI
|
||||
|
||||
|
||||
def cs(v_kph, standstill=False):
|
||||
return SimpleNamespace(vEgo=v_kph / 3.6, standstill=standstill)
|
||||
|
||||
|
||||
def run(sc, state, car, a_target, prev, secs, stock=None):
|
||||
"""step the controller for secs with a fixed plan; returns the last output"""
|
||||
out = prev
|
||||
for _ in range(int(secs / DT_CTRL)):
|
||||
_, out = sc.update(state, state, car, a_target, out, a_target if stock is None else stock, LIMITS, has_lead=True)
|
||||
return out
|
||||
|
||||
|
||||
def blend(sc, v_kph):
|
||||
return sc._blend(v_kph / 3.6)
|
||||
|
||||
|
||||
class TestEndOfStop(OpenpilotTestCase):
|
||||
def test_the_line_runs_from_the_entry_request_through_5kph_to_the_rest_value(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = run(sc, PID, cs(9.9), -1.20, -1.20, 0.2)
|
||||
assert sc.end_active and abs(sc.a_entry - (-1.20)) < 1e-6
|
||||
# 7.5 km/h: halfway between the entry request and END_HI; the planner has eased to -0.30, the line is sent
|
||||
out = run(sc, PID, cs(7.5), -0.30, out, 0.6)
|
||||
assert abs(blend(sc, 7.5) - (-1.20 + (END_HI - -1.20) * 0.5)) < 1e-6
|
||||
assert abs(out - blend(sc, 7.5)) < 1e-6
|
||||
# 5 km/h: END_HI; below it a parabola: steep first, flat toward the rest value; the wheel stop: END_LO
|
||||
out = run(sc, PID, cs(5.0), -0.20, out, 0.4)
|
||||
assert abs(out - END_HI) < 1e-6
|
||||
out = run(sc, PID, cs(2.5), -0.20, out, 0.4)
|
||||
assert abs(out - (-0.48 + -0.60) / 2) < 1e-6
|
||||
assert abs(blend(sc, 4.0) - (-0.65)) < 1e-6 and abs(blend(sc, 1.0) - (-0.30)) < 1e-6
|
||||
assert abs(blend(sc, 0.5) - (-0.30)) < 1e-6 # 1-0 km/h unchanged
|
||||
out = run(sc, PID, cs(0.0), -0.20, out, 0.4)
|
||||
assert abs(out - END_LO) < 1e-6
|
||||
|
||||
def test_the_line_is_a_floor_a_firmer_plan_still_passes(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = run(sc, PID, cs(9.9), -1.20, -1.20, 0.2)
|
||||
out = run(sc, PID, cs(5.0), -1.10, out, 0.5)
|
||||
assert abs(out - (-1.10)) < 1e-6
|
||||
out = run(sc, PID, cs(2.5), -1.8, out, 0.6)
|
||||
assert abs(out - (-1.8)) < 1e-6
|
||||
|
||||
def test_a_light_plan_at_entry_starts_the_line_at_end_hi(self):
|
||||
sc = StoppingController(-2.0)
|
||||
light = END_HI + 0.1
|
||||
out = run(sc, PID, cs(7.0), light, light, 0.5)
|
||||
assert abs(sc.a_entry - END_HI) < 1e-6
|
||||
assert abs(out - END_HI) < 1e-6
|
||||
# the planner's own curve eases off below 5 km/h: the request follows the line, never the plan
|
||||
out = run(sc, PID, cs(2.5), -0.10, out, 0.5)
|
||||
assert abs(out - blend(sc, 2.5)) < 1e-6
|
||||
|
||||
def test_entry_is_rate_limited(self):
|
||||
sc = StoppingController(-2.0)
|
||||
light = END_HI + 0.2
|
||||
_, out = sc.update(PID, PID, cs(7.0), light, light, light, LIMITS, has_lead=True)
|
||||
assert out > END_HI
|
||||
assert abs(out - (light - StoppingController.END_RATE * DT_CTRL)) < 1e-6
|
||||
|
||||
def test_an_emergency_plan_still_passes(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = run(sc, PID, cs(2.5), -1.8, -0.40, 0.8)
|
||||
assert abs(out - (-1.8)) < 1e-6
|
||||
|
||||
def test_crawl_follow_is_untouched(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = run(sc, PID, cs(2.0), -0.10, -0.10, 0.5)
|
||||
assert abs(out - (-0.10)) < 1e-6
|
||||
assert not sc.end_active
|
||||
|
||||
def test_above_blend_v_nothing_changes(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = run(sc, PID, cs(12.0), -0.40, -0.40, 0.5)
|
||||
assert abs(out - (-0.40)) < 1e-6
|
||||
assert not sc.end_active
|
||||
|
||||
def test_lead_moving_off_releases_the_hold(self):
|
||||
sc = StoppingController(-2.0)
|
||||
run(sc, PID, cs(2.0), -0.60, -0.60, 0.5)
|
||||
assert sc.end_active
|
||||
_, out = sc.update(PID, PID, cs(1.5), 0.50, END_LO, 0.50, LIMITS, has_lead=True)
|
||||
assert not sc.end_active and abs(out - 0.50) < 1e-6
|
||||
|
||||
def test_stopping_state_keeps_the_line_until_the_wheels_stop_then_holds(self):
|
||||
sc = StoppingController(-1.0)
|
||||
run(sc, PID, cs(2.0), END_HI, END_HI, 0.5)
|
||||
# 1 km/h, stopping state, the planner's curve has eased to -0.10: Kumar's lighter-follow would ease the request
|
||||
out = run(sc, STOPPING, cs(1.0), -0.10, blend(sc, 2.0), 0.4)
|
||||
assert abs(out - blend(sc, 1.0)) < 1e-6
|
||||
# wheels stop: the last value of the line is held until the hold delay, then ramps to stopAccel at the hold rate
|
||||
out_at_stop = run(sc, STOPPING, cs(0.0, standstill=True), -0.10, out, 0.9)
|
||||
assert abs(out_at_stop - out) < 1e-6 and END_HI < out_at_stop <= END_LO + 1e-6
|
||||
out_held = run(sc, STOPPING, cs(0.0, standstill=True), -0.10, out_at_stop, 1.0)
|
||||
assert out_held < out_at_stop - 0.3
|
||||
assert out_held >= -1.0 - 1e-6
|
||||
|
||||
|
||||
class TestHoldRelease(OpenpilotTestCase):
|
||||
"""2026-10-04 14:43: leaving the hold is fast while the request is far below zero, then linear and slow through the
|
||||
hand-over band, and the plan is never exceeded."""
|
||||
|
||||
def test_release_is_slow_through_the_handover_band(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = -2.0
|
||||
t = 0.0
|
||||
t_lo = t_hi = None
|
||||
state = STOPPING # controlsd feeds the returned state back as prev_state (the 0.2 s exit debounce lives there)
|
||||
for _ in range(300):
|
||||
state, out = sc.update(state, PID, cs(0.0, standstill=True), 0.8, out, 0.8, LIMITS, has_lead=True)
|
||||
t += DT_CTRL
|
||||
if t_lo is None and out >= StoppingController.RELEASE_BAND_LO:
|
||||
t_lo = t
|
||||
if t_hi is None and out >= StoppingController.RELEASE_BAND_HI:
|
||||
t_hi = t
|
||||
assert out <= 0.8 + 1e-9
|
||||
assert t_lo is not None and t_lo < 0.6 # 0.2 s debounce, then -2.0 -> -0.6 at the fast rate (0.35 s)
|
||||
assert t_hi is not None and 1.15 < t_hi < 1.3 # then -0.6 -> +0.4 at 1.5 m/s^3 (0.67 s)
|
||||
assert abs(out - 0.8) < 1e-9 and not sc.releasing
|
||||
|
||||
def test_a_plan_that_drops_again_ends_the_release(self):
|
||||
sc = StoppingController(-2.0)
|
||||
state, out = STOPPING, -2.0
|
||||
for _ in range(30): # through the exit debounce
|
||||
state, out = sc.update(state, PID, cs(0.0, standstill=True), 0.5, out, 0.5, LIMITS, has_lead=True)
|
||||
assert sc.releasing and -2.0 < out < 0.5
|
||||
_, out = sc.update(state, PID, cs(0.0, standstill=True), -2.0, out, -2.0, LIMITS, has_lead=True)
|
||||
assert abs(out - (-2.0)) < 1e-9 and not sc.releasing
|
||||
|
||||
|
||||
class TestEngageAtStandstill(OpenpilotTestCase):
|
||||
def _engage(self, has_lead, secs):
|
||||
sc = StoppingController(-2.0)
|
||||
car = cs(0.0, standstill=True)
|
||||
for _ in range(300): # standing in the auto hold, openpilot not engaged
|
||||
sc.update(OFF, OFF, car, 0.0, 0.0, 0.0, LIMITS, has_lead=has_lead)
|
||||
out = 0.0
|
||||
for _ in range(int(secs / DT_CTRL)):
|
||||
_, out = sc.update(STOPPING, STOPPING, car, 0.0, out, 0.0, LIMITS, has_lead=has_lead)
|
||||
return out, sc
|
||||
|
||||
def test_no_brake_pulse_right_after_engaging_without_lead(self):
|
||||
# route 0000010a 23:58:49: the hold ramp reached -0.21 in 0.2 s and the PCM braked 1600 N before the launch
|
||||
out, sc = self._engage(False, 0.5)
|
||||
assert out == 0.0
|
||||
|
||||
def test_the_hold_ramp_follows_after_the_grace(self):
|
||||
out, sc = self._engage(False, StoppingController.ENGAGE_STANDSTILL_GRACE + 0.3)
|
||||
assert out < -0.2
|
||||
|
||||
def test_with_a_lead_the_hold_starts_at_once(self):
|
||||
out, _ = self._engage(True, 0.3)
|
||||
assert out < -0.2
|
||||
|
||||
|
||||
def stand(sc, secs, out=StoppingController.END_LO, pitch=None, a_target=-0.1):
|
||||
state = STOPPING
|
||||
for _ in range(int(secs / DT_CTRL)):
|
||||
state, out = sc.update(state, STOPPING, cs(0.0, standstill=True), a_target, out, a_target, LIMITS, has_lead=True, pitch=pitch)
|
||||
return out
|
||||
|
||||
|
||||
FLAT = math.radians(1.0)
|
||||
SLOPE = math.radians(-4.0)
|
||||
|
||||
|
||||
class TestRerollHold(OpenpilotTestCase):
|
||||
"""route 0000010e 17:38:48: stood at -0.30, rolled at 0.8 km/h for 1.1 s inside the hold delay"""
|
||||
|
||||
def _reroll(self, sc):
|
||||
out = stand(sc, 0.9)
|
||||
state = STOPPING
|
||||
outs = []
|
||||
for _ in range(20):
|
||||
state, out = sc.update(state, STOPPING, cs(0.8), -0.05, out, -0.05, LIMITS, has_lead=True)
|
||||
outs.append(out)
|
||||
return outs
|
||||
|
||||
def test_steps_to_the_reroll_request_within_0_1s(self):
|
||||
outs = self._reroll(StoppingController(-2.0))
|
||||
assert outs[4] <= StoppingController.REROLL_ACCEL + 1e-9 # 0.05 s
|
||||
assert all(b - a >= -StoppingController.REROLL_JERK * DT_CTRL - 1e-9 for a, b in zip(outs, outs[1:], strict=False))
|
||||
|
||||
|
||||
class TestFlatHold(OpenpilotTestCase):
|
||||
def test_flat_hold_stops_at_minus_1_2(self):
|
||||
out = stand(StoppingController(-2.0), 4.0, pitch=FLAT)
|
||||
assert abs(out - StoppingController.FLAT_HOLD_ACCEL) < 1e-9
|
||||
|
||||
def test_slope_or_no_pitch_keeps_stop_accel(self):
|
||||
assert stand(StoppingController(-2.0), 4.0, pitch=SLOPE) <= -2.0 + 1e-6
|
||||
assert stand(StoppingController(-2.0), 4.0, pitch=None) <= -2.0 + 1e-6
|
||||
|
||||
def test_a_creep_on_the_flat_hold_goes_deeper(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = stand(sc, 4.0, pitch=FLAT)
|
||||
state = STOPPING
|
||||
for _ in range(150):
|
||||
state, out = sc.update(state, STOPPING, cs(0.8), -0.1, out, -0.1, LIMITS, has_lead=True, pitch=FLAT)
|
||||
assert out < StoppingController.FLAT_HOLD_ACCEL - 0.5
|
||||
|
||||
|
||||
class TestCreepFollowStop(OpenpilotTestCase):
|
||||
"""route 0000010d 23:56:59: lead moved ~2 m, the car followed at 2.5 km/h and the brake bit at -0.88"""
|
||||
|
||||
def _creep(self, sc, plan, v_kph=2.0, secs=2.0, out=0.4):
|
||||
stand(sc, 2.0)
|
||||
outs = []
|
||||
for _ in range(int(secs / DT_CTRL)):
|
||||
_, out = sc.update(PID, PID, cs(v_kph), plan, out, plan, LIMITS, has_lead=True)
|
||||
outs.append(out)
|
||||
return outs
|
||||
|
||||
def test_stops_with_minus_0_24_not_the_end_line(self):
|
||||
outs = self._creep(StoppingController(-2.0), -0.40)
|
||||
assert outs[0] <= 0.0 # a positive request is dropped at once
|
||||
assert abs(outs[-1] - StoppingController.CF_END) < 1e-9
|
||||
steps = [a - b for a, b in zip(outs, outs[1:], strict=False)]
|
||||
assert max(steps) <= StoppingController.CF_RATE * DT_CTRL + 1e-9
|
||||
|
||||
def test_a_firm_plan_still_wins(self):
|
||||
outs = self._creep(StoppingController(-2.0), -1.0)
|
||||
assert abs(outs[-1] - (-1.0)) < 1e-9
|
||||
|
||||
def test_light_plan_keeps_minus_0_24_to_the_stop(self):
|
||||
sc = StoppingController(-2.0)
|
||||
outs = self._creep(sc, -0.40, secs=1.0)
|
||||
_, out = sc.update(PID, PID, cs(1.0), -0.10, outs[-1], -0.10, LIMITS, has_lead=True)
|
||||
assert abs(out - StoppingController.CF_END) < 1e-9
|
||||
|
||||
def test_not_after_a_real_launch(self):
|
||||
sc = StoppingController(-2.0)
|
||||
stand(sc, 2.0)
|
||||
out = 0.5
|
||||
for _ in range(300):
|
||||
_, out = sc.update(PID, PID, cs(15.0), 0.3, out, 0.3, LIMITS, has_lead=True)
|
||||
_, out = sc.update(PID, PID, cs(6.0), -0.40, out, -0.40, LIMITS, has_lead=True)
|
||||
assert sc.cf_t is None
|
||||
|
||||
def test_lead_moving_off_ends_it(self):
|
||||
sc = StoppingController(-2.0)
|
||||
outs = self._creep(sc, -0.40, secs=0.5)
|
||||
_, out = sc.update(PID, PID, cs(2.0), 0.5, outs[-1], 0.5, LIMITS, has_lead=True)
|
||||
assert out == 0.5 and not sc.cf_stopping
|
||||
|
||||
def test_after_the_creep_stop_a_little_brake_at_once_then_the_hold_slowly(self):
|
||||
"""route 00000112 23:16:57: -0.24 for the 1.0 s hold delay, then -1.2 within ~1 s felt like a second brake"""
|
||||
sc = StoppingController(-2.0)
|
||||
outs = self._creep(sc, -0.40)
|
||||
out, state, trace = outs[-1], STOPPING, []
|
||||
for _ in range(300):
|
||||
state, out = sc.update(state, STOPPING, cs(0.0, standstill=True), -0.05, out, -0.05, LIMITS, has_lead=True,
|
||||
pitch=FLAT)
|
||||
trace.append(out)
|
||||
assert trace[50] <= StoppingController.CF_SETTLE_ACCEL + 1e-9 # -0.6 within 0.5 s, no hold delay
|
||||
assert trace[10] > StoppingController.CF_SETTLE_ACCEL # but gradually
|
||||
assert abs(trace[150] - (StoppingController.CF_SETTLE_ACCEL - 1.0 * StoppingController.CF_HOLD_RATE)) < 0.02
|
||||
assert abs(trace[-1] - StoppingController.FLAT_HOLD_ACCEL) < 1e-9 # the flat hold after ~2.5 s
|
||||
steps = [a - b for a, b in zip(trace, trace[1:], strict=False)]
|
||||
assert max(steps) <= StoppingController.CF_SETTLE_JERK * DT_CTRL + 1e-9
|
||||
|
||||
def test_an_ordinary_stop_keeps_the_hold_delay(self):
|
||||
sc = StoppingController(-2.0)
|
||||
out = stand(sc, 0.9, pitch=FLAT)
|
||||
assert abs(out - END_LO) < 1e-9
|
||||
|
||||
|
||||
class TestConfirmedStopEarlyHold(OpenpilotTestCase):
|
||||
"""hold 0.2 s after a CONFIRMED stop (standstill flag + the body's deceleration collapsing), else the 1.0 s delay"""
|
||||
|
||||
def _stop(self, sc, collapse):
|
||||
out, state = -0.40, PID
|
||||
for k in range(100): # rolling to the stop at -0.45 m/s^2 (body)
|
||||
state, out = sc.update(state, STOPPING, cs(2.0 - 0.019 * k), -0.3, out, -0.3, LIMITS, has_lead=True, pitch=FLAT, a_long=-0.45)
|
||||
trace = []
|
||||
for k in range(150): # standing: the body pitches back (deceleration collapses) after 0.1 s, or not
|
||||
a = -0.05 if (collapse and k >= 10) else -0.45
|
||||
state, out = sc.update(state, STOPPING, cs(0.0, True), -0.05, out, -0.05, LIMITS, has_lead=True, pitch=FLAT, a_long=a)
|
||||
trace.append(out)
|
||||
return trace
|
||||
|
||||
def test_confirmed_stop_holds_after_0_2s(self):
|
||||
sc = StoppingController(-2.0)
|
||||
tr = self._stop(sc, True)
|
||||
assert tr[25] == tr[0] # 0.1 s to the pitch-back + 0.2 s: nothing yet at 0.25 s
|
||||
assert tr[40] < tr[0] - 0.05 # holding by 0.4 s
|
||||
|
||||
def test_no_confirmation_keeps_the_1s_delay(self):
|
||||
sc = StoppingController(-2.0)
|
||||
tr = self._stop(sc, False)
|
||||
assert tr[90] == tr[0] and tr[110] < tr[0]
|
||||
|
||||
def test_no_pose_keeps_the_1s_delay(self):
|
||||
sc = StoppingController(-2.0)
|
||||
assert not sc.stop_confirm.update(True, 0.0, None)
|
||||
@@ -1,433 +0,0 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from opendbc.car.interfaces import ACCEL_MAX, ACCEL_MIN
|
||||
from openpilot.common.realtime import DT_CTRL, DT_MDL, Ratekeeper
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongControl, LongCtrlState
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_planner import LongitudinalPlanner
|
||||
from openpilot.selfdrive.controls.radard import _LEAD_ACCEL_TAU
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant, PlannerSM
|
||||
|
||||
|
||||
LeadObservation = dict[str, Any]
|
||||
LeadObservationFn = Callable[[float, str, LeadObservation], LeadObservation | None]
|
||||
ModelActionFn = Callable[[float, float, float], tuple[float, bool]]
|
||||
EgoObservationFn = Callable[[float, float, float], tuple[float, float]]
|
||||
ModelPlanFn = Callable[[float, float, float], list[float]]
|
||||
ModelMetaFn = Callable[[float], tuple[list[float], bool, float]]
|
||||
LeadFutureProbsFn = Callable[[float], tuple[float, float, float]]
|
||||
PositionYFn = Callable[[float], list[float]]
|
||||
ExperimentalModeFn = Callable[[float], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActuatorModel:
|
||||
planner_delay: float
|
||||
transport_delay: float
|
||||
actuator_lag: float
|
||||
command_rate_limit: float
|
||||
stopping_acceleration: float
|
||||
standstill_breakaway_acceleration: float
|
||||
standstill_breakaway_time: float
|
||||
|
||||
def __post_init__(self):
|
||||
nonnegative_fields = {
|
||||
"planner_delay": self.planner_delay,
|
||||
"transport_delay": self.transport_delay,
|
||||
"actuator_lag": self.actuator_lag,
|
||||
"standstill_breakaway_acceleration": self.standstill_breakaway_acceleration,
|
||||
"standstill_breakaway_time": self.standstill_breakaway_time,
|
||||
}
|
||||
if any(not math.isfinite(value) or value < 0.0 for value in nonnegative_fields.values()):
|
||||
raise ValueError(f"ActuatorModel fields must be finite and non-negative: {nonnegative_fields}")
|
||||
if not math.isfinite(self.command_rate_limit) or self.command_rate_limit <= 0.0:
|
||||
raise ValueError("command_rate_limit must be finite and positive")
|
||||
if not math.isfinite(self.stopping_acceleration) or self.stopping_acceleration > 0.0:
|
||||
raise ValueError("stopping_acceleration must be finite and non-positive")
|
||||
|
||||
|
||||
# Conservative Prius TSS2 actuator model.
|
||||
PRIUS_TSS2_ROUTE_MODEL = ActuatorModel(
|
||||
planner_delay=0.05,
|
||||
transport_delay=0.0,
|
||||
actuator_lag=0.20,
|
||||
command_rate_limit=4.0,
|
||||
stopping_acceleration=-2.0,
|
||||
standstill_breakaway_acceleration=1.0,
|
||||
standstill_breakaway_time=0.05,
|
||||
)
|
||||
|
||||
|
||||
class PlantSP(Plant):
|
||||
"""Closed-loop plant with configurable observations and actuator response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lead_relevancy=False,
|
||||
speed=0.0,
|
||||
distance_lead=2.0,
|
||||
enabled=True,
|
||||
only_lead2=False,
|
||||
only_radar=False,
|
||||
e2e=False,
|
||||
personality=0,
|
||||
force_decel=False,
|
||||
lead_observation_fn: LeadObservationFn | None = None,
|
||||
model_action_fn: ModelActionFn | None = None,
|
||||
ego_observation_fn: EgoObservationFn | None = None,
|
||||
model_plan_fn: ModelPlanFn | None = None,
|
||||
model_meta_fn: ModelMetaFn | None = None,
|
||||
lead_future_probs_fn: LeadFutureProbsFn | None = None,
|
||||
position_y_fn: PositionYFn | None = None,
|
||||
experimental_mode_fn: ExperimentalModeFn | None = None,
|
||||
actuator_delay: float | None = None,
|
||||
actuator_lag: float = 0.0,
|
||||
actuator_model: ActuatorModel | None = None,
|
||||
run_long_control: bool = False,
|
||||
):
|
||||
if actuator_delay is not None and (not math.isfinite(actuator_delay) or actuator_delay < 0.0):
|
||||
raise ValueError("actuator_delay must be finite and non-negative")
|
||||
if not math.isfinite(actuator_lag) or actuator_lag < 0.0:
|
||||
raise ValueError("actuator_lag must be finite and non-negative")
|
||||
|
||||
self.rate = 1.0 / DT_MDL
|
||||
|
||||
if not Plant.messaging_initialized:
|
||||
Plant.radar = messaging.pub_sock('radarState')
|
||||
Plant.controls_state = messaging.pub_sock('controlsState')
|
||||
Plant.selfdrive_state = messaging.pub_sock('selfdriveState')
|
||||
Plant.car_state = messaging.pub_sock('carState')
|
||||
Plant.plan = messaging.sub_sock('longitudinalPlan')
|
||||
Plant.messaging_initialized = True
|
||||
|
||||
self.v_lead_prev = 0.0
|
||||
|
||||
self.distance = 0.0
|
||||
self.speed = speed
|
||||
self.should_stop = False
|
||||
self.acceleration = 0.0
|
||||
self.a_target = 0.0
|
||||
self.actuator_command = 0.0
|
||||
self.applied_actuator_command = 0.0
|
||||
self.breakaway_confirmed = False
|
||||
self._breakaway_timer = 0.0
|
||||
|
||||
# lead car
|
||||
self.lead_relevancy = lead_relevancy
|
||||
self.distance_lead = distance_lead
|
||||
self.enabled = enabled
|
||||
self.only_lead2 = only_lead2
|
||||
self.only_radar = only_radar
|
||||
self.e2e = e2e
|
||||
self.personality = personality
|
||||
self.force_decel = force_decel
|
||||
self.lead_observation_fn = lead_observation_fn
|
||||
self.model_action_fn = model_action_fn
|
||||
self.ego_observation_fn = ego_observation_fn
|
||||
self.model_plan_fn = model_plan_fn
|
||||
self.model_meta_fn = model_meta_fn
|
||||
self.lead_future_probs_fn = lead_future_probs_fn
|
||||
self.position_y_fn = position_y_fn
|
||||
self.experimental_mode_fn = experimental_mode_fn
|
||||
self.actuator_model = actuator_model
|
||||
self.actuator_delay = actuator_model.planner_delay if actuator_model is not None else actuator_delay
|
||||
self.transport_delay = actuator_model.transport_delay if actuator_model is not None else actuator_delay
|
||||
self.actuator_lag = actuator_model.actuator_lag if actuator_model is not None else actuator_lag
|
||||
self.publish_realized_a_ego = any((lead_observation_fn is not None, model_action_fn is not None, ego_observation_fn is not None,
|
||||
actuator_delay is not None, actuator_lag > 0.0, actuator_model is not None, run_long_control))
|
||||
|
||||
self.rk = Ratekeeper(self.rate, print_delay_threshold=100.0)
|
||||
self.ts = 1.0 / self.rate
|
||||
time.sleep(0.1)
|
||||
self.sm = messaging.SubMaster(['longitudinalPlan'])
|
||||
|
||||
from opendbc.car.honda.values import CAR
|
||||
from opendbc.car.honda.interface import CarInterface
|
||||
|
||||
CP = CarInterface.get_non_essential_params(CAR.HONDA_CIVIC)
|
||||
if self.actuator_delay is not None:
|
||||
CP.longitudinalActuatorDelay = self.actuator_delay
|
||||
CP_SP = CarInterface.get_non_essential_params_sp(CP, CAR.HONDA_CIVIC)
|
||||
self.planner = LongitudinalPlanner(CP, CP_SP, init_v=self.speed)
|
||||
self.long_control = LongControl(CP, CP_SP) if run_long_control else None
|
||||
|
||||
if self.actuator_model is not None and self.speed >= 0.01:
|
||||
self.breakaway_confirmed = True
|
||||
self.integration_dt = DT_CTRL if run_long_control else self.ts
|
||||
delay_steps = 0 if self.transport_delay is None else round(self.transport_delay / self.integration_dt)
|
||||
self._actuator_delay_queue = deque([self.acceleration] * delay_steps)
|
||||
|
||||
@staticmethod
|
||||
def _lead_message(observation: LeadObservation):
|
||||
lead = log.RadarState.LeadData.new_message()
|
||||
for field, value in observation.items():
|
||||
setattr(lead, field, value)
|
||||
return lead
|
||||
|
||||
def _observe_lead(self, lead_name: str, truth: LeadObservation, present_by_default: bool) -> LeadObservation | None:
|
||||
if self.lead_observation_fn is None:
|
||||
return dict(truth) if present_by_default else None
|
||||
|
||||
observed = self.lead_observation_fn(self.current_time, lead_name, dict(truth))
|
||||
if observed is None:
|
||||
return None
|
||||
|
||||
complete_observation = dict(truth)
|
||||
complete_observation.update(observed)
|
||||
return complete_observation
|
||||
|
||||
def _update_actuator(self, command: float) -> tuple[float, float]:
|
||||
if self._actuator_delay_queue:
|
||||
self._actuator_delay_queue.append(command)
|
||||
delayed_command = self._actuator_delay_queue.popleft()
|
||||
else:
|
||||
delayed_command = command
|
||||
|
||||
if self.actuator_model is not None:
|
||||
max_command_delta = self.actuator_model.command_rate_limit * self.integration_dt
|
||||
self.applied_actuator_command = float(np.clip(delayed_command,
|
||||
self.applied_actuator_command - max_command_delta,
|
||||
self.applied_actuator_command + max_command_delta))
|
||||
|
||||
if self.speed < 0.01:
|
||||
if self.applied_actuator_command <= 0.0:
|
||||
self.breakaway_confirmed = False
|
||||
self._breakaway_timer = 0.0
|
||||
elif not self.breakaway_confirmed:
|
||||
breakaway_ready = self.applied_actuator_command + 1e-9 >= self.actuator_model.standstill_breakaway_acceleration
|
||||
if breakaway_ready:
|
||||
self._breakaway_timer += self.integration_dt
|
||||
else:
|
||||
self._breakaway_timer = 0.0
|
||||
|
||||
self.breakaway_confirmed = breakaway_ready and self._breakaway_timer + 1e-9 >= self.actuator_model.standstill_breakaway_time
|
||||
if not self.breakaway_confirmed:
|
||||
self.acceleration = 0.0
|
||||
return delayed_command, self.acceleration
|
||||
else:
|
||||
self.breakaway_confirmed = True
|
||||
|
||||
response_command = self.applied_actuator_command
|
||||
else:
|
||||
self.applied_actuator_command = delayed_command
|
||||
response_command = delayed_command
|
||||
|
||||
if self.actuator_lag > 0.0:
|
||||
alpha = 1.0 - math.exp(-self.integration_dt / self.actuator_lag)
|
||||
self.acceleration += alpha * (response_command - self.acceleration)
|
||||
else:
|
||||
self.acceleration = response_command
|
||||
return delayed_command, self.acceleration
|
||||
|
||||
def _integrate_ego(self, dt: float, stop_at_standstill: bool = False) -> None:
|
||||
self.speed += self.acceleration * dt
|
||||
if self.speed <= 0.0 or stop_at_standstill and self.speed < 0.01 and self.actuator_command <= 0.0:
|
||||
self.speed = self.acceleration = 0.0
|
||||
self.distance += self.speed * dt
|
||||
|
||||
def step(self, v_lead=0.0, prob_lead=1.0, v_cruise=50.0, pitch=0.0, prob_throttle=1.0):
|
||||
# ******** publish a fake model going straight and fake calibration ********
|
||||
# note that this is worst case for MPC, since model will delay long mpc by one time step
|
||||
radar = messaging.new_message('radarState')
|
||||
control = messaging.new_message('controlsState')
|
||||
ss = messaging.new_message('selfdriveState')
|
||||
car_state = messaging.new_message('carState')
|
||||
vehicle_parameters = messaging.new_message('vehicleParameters')
|
||||
car_control = messaging.new_message('carControl')
|
||||
model = messaging.new_message('modelV2')
|
||||
car_state_sp = messaging.new_message('carStateSP')
|
||||
live_map_data_sp = messaging.new_message('liveMapDataSP')
|
||||
gps_data = messaging.new_message('gpsLocation')
|
||||
a_lead = (v_lead - self.v_lead_prev) / self.ts
|
||||
self.v_lead_prev = v_lead
|
||||
|
||||
if self.lead_relevancy:
|
||||
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
|
||||
v_rel = v_lead - self.speed
|
||||
if self.only_radar:
|
||||
status = True
|
||||
elif prob_lead > 0.5:
|
||||
status = True
|
||||
else:
|
||||
status = False
|
||||
else:
|
||||
d_rel = 200.0
|
||||
v_rel = 0.0
|
||||
prob_lead = 0.0
|
||||
status = False
|
||||
|
||||
truth_lead: LeadObservation = {
|
||||
"dRel": float(d_rel),
|
||||
"yRel": 0.0,
|
||||
"vRel": float(v_rel),
|
||||
"vLead": float(v_lead),
|
||||
"vLeadK": float(v_lead),
|
||||
"aLeadK": float(a_lead),
|
||||
"present": bool(status),
|
||||
# TODO use real radard logic for this
|
||||
"aLeadTau": float(_LEAD_ACCEL_TAU),
|
||||
"modelProb": float(prob_lead),
|
||||
"radar": bool(self.only_radar),
|
||||
"radarTrackId": -1,
|
||||
}
|
||||
lead_one_observation = self._observe_lead("leadOne", truth_lead, not self.only_lead2)
|
||||
lead_two_observation = self._observe_lead("leadTwo", truth_lead, True)
|
||||
if lead_one_observation is not None:
|
||||
radar.radarState.leadOne = self._lead_message(lead_one_observation)
|
||||
if lead_two_observation is not None:
|
||||
radar.radarState.leadTwo = self._lead_message(lead_two_observation)
|
||||
|
||||
# Simulate model predicting slightly faster speed
|
||||
# this is to ensure lead policy is effective when model
|
||||
# does not predict slowdown in e2e mode
|
||||
position = log.XYZTData.new_message()
|
||||
position.x = [float(x) for x in (self.speed + 0.5) * np.array(ModelConstants.T_IDXS)]
|
||||
if self.position_y_fn is None:
|
||||
position.y = [0.0] * len(ModelConstants.T_IDXS)
|
||||
else:
|
||||
position.y = [float(y) for y in self.position_y_fn(self.current_time)]
|
||||
model.modelV2.position = position
|
||||
if self.model_action_fn is None:
|
||||
model_acceleration, model_should_stop = self.acceleration + 0.5, False
|
||||
else:
|
||||
model_acceleration, model_should_stop = self.model_action_fn(self.current_time, self.speed, self.acceleration)
|
||||
model.modelV2.action.desiredAcceleration = float(model_acceleration)
|
||||
model.modelV2.action.shouldStop = bool(model_should_stop)
|
||||
velocity = log.XYZTData.new_message()
|
||||
if self.model_plan_fn is None:
|
||||
velocity_plan = [float(x) for x in (self.speed + 0.5) * np.ones_like(ModelConstants.T_IDXS)]
|
||||
velocity_plan[0] = float(self.speed) # always start at current speed
|
||||
else:
|
||||
velocity_plan = [float(x) for x in self.model_plan_fn(self.current_time, self.speed, self.acceleration)]
|
||||
velocity.x = velocity_plan
|
||||
model.modelV2.velocity = velocity
|
||||
acceleration = log.XYZTData.new_message()
|
||||
acceleration.x = [float(x) for x in np.zeros_like(ModelConstants.T_IDXS)]
|
||||
model.modelV2.acceleration = acceleration
|
||||
model.modelV2.meta.disengagePredictions.gasPressProbs = [float(prob_throttle) for _ in range(6)]
|
||||
if self.model_meta_fn is None:
|
||||
brake3_probs, hard_brake_predicted, frame_drop_perc = [0.0] * 5, False, 0.0
|
||||
else:
|
||||
brake3_probs, hard_brake_predicted, frame_drop_perc = self.model_meta_fn(self.current_time)
|
||||
model.modelV2.meta.disengagePredictions.brake3MetersPerSecondSquaredProbs = [float(p) for p in brake3_probs]
|
||||
model.modelV2.meta.hardBrakePredicted = bool(hard_brake_predicted)
|
||||
model.modelV2.frameDropPerc = float(frame_drop_perc)
|
||||
if self.lead_future_probs_fn is not None:
|
||||
model.modelV2.init('leadsV3', 3)
|
||||
lead_future_probs = self.lead_future_probs_fn(self.current_time)
|
||||
for i, (prob, prob_time) in enumerate(zip(lead_future_probs, (0.0, 2.0, 4.0), strict=True)):
|
||||
model.modelV2.leadsV3[i].prob = float(prob)
|
||||
model.modelV2.leadsV3[i].probTime = prob_time
|
||||
|
||||
control.controlsState.longControlState = self.long_control.long_control_state if self.long_control is not None else (
|
||||
LongCtrlState.pid if self.enabled else LongCtrlState.off)
|
||||
ss.selfdriveState.experimentalMode = self.e2e if self.experimental_mode_fn is None else bool(self.experimental_mode_fn(self.current_time))
|
||||
ss.selfdriveState.personality = self.personality
|
||||
control.controlsState.forceDecel = self.force_decel
|
||||
true_v_ego = self.speed
|
||||
true_a_ego = self.acceleration
|
||||
published_v_ego = true_v_ego
|
||||
published_a_ego = true_a_ego if self.publish_realized_a_ego else 0.0
|
||||
if self.ego_observation_fn is not None:
|
||||
published_v_ego, published_a_ego = self.ego_observation_fn(self.current_time, true_v_ego, true_a_ego)
|
||||
car_state.carState.vEgo = float(published_v_ego)
|
||||
car_state.carState.aEgo = float(published_a_ego)
|
||||
car_state.carState.standstill = bool(self.speed < 0.01)
|
||||
car_state.carState.vCruise = float(v_cruise * 3.6)
|
||||
car_control.carControl.orientationNED = [0.0, float(pitch), 0.0]
|
||||
|
||||
# ******** get controlsState messages for plotting ***
|
||||
sm = PlannerSM(self.rk.frame, {
|
||||
'radarState': radar.radarState,
|
||||
'carState': car_state.carState,
|
||||
'carControl': car_control.carControl,
|
||||
'controlsState': control.controlsState,
|
||||
'selfdriveState': ss.selfdriveState,
|
||||
'vehicleParameters': vehicle_parameters.vehicleParameters,
|
||||
'modelV2': model.modelV2,
|
||||
'carStateSP': car_state_sp.carStateSP,
|
||||
'liveMapDataSP': live_map_data_sp.liveMapDataSP,
|
||||
'gpsLocation': gps_data.gpsLocation,
|
||||
})
|
||||
self.planner.update(sm)
|
||||
self.a_target = self.planner.output_a_target
|
||||
if self.long_control is None:
|
||||
self.actuator_command = self.a_target
|
||||
if self.planner.output_should_stop:
|
||||
stopping_acceleration = -0.5 if self.actuator_model is None else self.actuator_model.stopping_acceleration
|
||||
self.actuator_command = min(stopping_acceleration, self.actuator_command)
|
||||
self._update_actuator(self.actuator_command)
|
||||
self._integrate_ego(self.ts)
|
||||
else:
|
||||
for _ in range(round(self.ts / DT_CTRL)):
|
||||
car_state.carState.vEgo = self.speed
|
||||
car_state.carState.aEgo = self.acceleration
|
||||
car_state.carState.standstill = self.speed < 0.01
|
||||
self.actuator_command = self.long_control.update(
|
||||
self.enabled, car_state.carState, self.a_target, self.planner.output_should_stop, (ACCEL_MIN, ACCEL_MAX),
|
||||
)
|
||||
self._update_actuator(self.actuator_command)
|
||||
self._integrate_ego(DT_CTRL, stop_at_standstill=True)
|
||||
self.should_stop = self.planner.output_should_stop
|
||||
fcw = self.planner.fcw
|
||||
self.distance_lead = self.distance_lead + v_lead * self.ts
|
||||
|
||||
# *** radar model ***
|
||||
if self.lead_relevancy:
|
||||
d_rel = np.maximum(0.0, self.distance_lead - self.distance)
|
||||
v_rel = v_lead - self.speed
|
||||
else:
|
||||
d_rel = 200.0
|
||||
v_rel = 0.0
|
||||
|
||||
# print at 5hz
|
||||
# if (self.rk.frame % (self.rate // 5)) == 0:
|
||||
# print("%2.2f sec %6.2f m %6.2f m/s %6.2f m/s2 lead_rel: %6.2f m %6.2f m/s"
|
||||
# % (self.current_time, self.distance, self.speed, self.acceleration, d_rel, v_rel))
|
||||
|
||||
# ******** update prevs ********
|
||||
self.rk.monitor_time()
|
||||
|
||||
return {
|
||||
"distance": self.distance,
|
||||
"speed": self.speed,
|
||||
"acceleration": self.acceleration,
|
||||
"realized_acceleration": self.acceleration,
|
||||
"a_target": self.a_target,
|
||||
"actuator_command": self.actuator_command,
|
||||
"published_a_ego": published_a_ego,
|
||||
"published_v_ego": published_v_ego,
|
||||
"should_stop": self.should_stop,
|
||||
"long_control_state": (int(self.long_control.long_control_state) if self.long_control is not None
|
||||
else control.controlsState.longControlState.raw),
|
||||
"distance_lead": self.distance_lead,
|
||||
"fcw": fcw,
|
||||
"mpc_source": self.planner.mpc.source,
|
||||
"dec_mode": self.planner.dec.mode(),
|
||||
"dec_want_blended": self.planner.dec.want_blended,
|
||||
"dec_signals": asdict(self.planner.dec.signals),
|
||||
"dec_lead_veto": self.planner.dec.lead_veto,
|
||||
"controller_active": self.planner.accel_controller_active,
|
||||
"model_action": {
|
||||
"desiredAcceleration": float(model_acceleration),
|
||||
"shouldStop": bool(model_should_stop),
|
||||
},
|
||||
"truth_lead": dict(truth_lead),
|
||||
"lead_one_observation": None if lead_one_observation is None else dict(lead_one_observation),
|
||||
"lead_two_observation": None if lead_two_observation is None else dict(lead_two_observation),
|
||||
}
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.dec.dec import ENTER_FRAMES, MIN_BLENDED_FRAMES
|
||||
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
|
||||
|
||||
T_IDXS = np.array(ModelConstants.T_IDXS)
|
||||
|
||||
|
||||
def decel_plan(a):
|
||||
def fn(_current_time, speed, _acceleration):
|
||||
return [float(max(0.0, speed + a * t)) for t in T_IDXS]
|
||||
return fn
|
||||
|
||||
|
||||
def flat_plan():
|
||||
def fn(_current_time, speed, _acceleration):
|
||||
return [float(speed)] * len(T_IDXS)
|
||||
return fn
|
||||
|
||||
|
||||
def alternating_plan(a):
|
||||
def fn(current_time, speed, _acceleration):
|
||||
frame_a = a if round(current_time / DT_MDL) % 2 == 0 else 0.0
|
||||
return [float(max(0.0, speed + frame_a * t)) for t in T_IDXS]
|
||||
return fn
|
||||
|
||||
|
||||
def persistent_lead_probs(_current_time):
|
||||
return (1.0, 0.95, 0.9)
|
||||
|
||||
|
||||
def _run(plant, steps, v_lead=0.0, v_cruise=50.0):
|
||||
solver_failures = 0
|
||||
original_reset = plant.planner.mpc.reset
|
||||
|
||||
def counting_reset(*args, **kw):
|
||||
nonlocal solver_failures
|
||||
if plant.planner.mpc.solution_status != 0:
|
||||
solver_failures += 1
|
||||
return original_reset(*args, **kw)
|
||||
|
||||
plant.planner.mpc.reset = counting_reset
|
||||
return [plant.step(v_lead=v_lead, v_cruise=v_cruise) for _ in range(steps)], solver_failures
|
||||
|
||||
|
||||
def mode_changes(results):
|
||||
modes = [r["dec_mode"] for r in results]
|
||||
return sum(a != b for a, b in zip(modes, modes[1:], strict=False))
|
||||
|
||||
|
||||
class TestDecManeuvers(OpenpilotTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.params = Params()
|
||||
self.params.put_bool("DynamicExperimentalControl", True, block=True)
|
||||
|
||||
def test_s1_lead_clears_with_underlying_slowdown_blends_quickly(self):
|
||||
clear_t = 1.0
|
||||
|
||||
def lead_obs(current_time, _lead_name, truth):
|
||||
return None if current_time >= clear_t else dict(truth)
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=20.0, distance_lead=40.0, e2e=True, only_radar=True,
|
||||
lead_observation_fn=lead_obs, model_plan_fn=decel_plan(-2.5),
|
||||
lead_future_probs_fn=persistent_lead_probs)
|
||||
clear_frame = round(clear_t / DT_MDL)
|
||||
results, _ = _run(plant, steps=clear_frame + ENTER_FRAMES + 5, v_lead=20.0, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results[:clear_frame])
|
||||
assert all(r["dec_lead_veto"] for r in results[:clear_frame])
|
||||
post_clear = [r["dec_mode"] for r in results[clear_frame:clear_frame + ENTER_FRAMES + 2]]
|
||||
assert "blended" in post_clear
|
||||
|
||||
def test_s1b_lead_clears_with_no_underlying_slowdown_stays_acc(self):
|
||||
clear_t = 1.0
|
||||
|
||||
def lead_obs(current_time, _lead_name, truth):
|
||||
return None if current_time >= clear_t else dict(truth)
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=20.0, distance_lead=40.0, e2e=True, only_radar=True,
|
||||
lead_observation_fn=lead_obs, model_plan_fn=flat_plan(),
|
||||
lead_future_probs_fn=persistent_lead_probs)
|
||||
clear_frame = round(clear_t / DT_MDL)
|
||||
results, _ = _run(plant, steps=clear_frame + MIN_BLENDED_FRAMES, v_lead=20.0, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s2_steady_highway_following_never_blends(self):
|
||||
v = 80.0 / 3.6
|
||||
plant = PlantSP(lead_relevancy=True, speed=v, distance_lead=40.0, e2e=True, only_radar=True,
|
||||
model_plan_fn=flat_plan(), lead_future_probs_fn=persistent_lead_probs)
|
||||
results, failures = _run(plant, steps=100, v_lead=v, v_cruise=v)
|
||||
|
||||
assert failures <= 1
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s3_low_speed_cruise_no_lead_never_blends(self):
|
||||
v = 15.0 / 3.6
|
||||
plant = PlantSP(lead_relevancy=False, speed=v, e2e=True, model_plan_fn=flat_plan())
|
||||
results, _ = _run(plant, steps=100, v_cruise=v)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s4_highway_slowdown_without_lead_blends(self):
|
||||
v0 = 110.0 / 3.6
|
||||
a = (70.0 / 3.6 - v0) / 6.0
|
||||
plant = PlantSP(lead_relevancy=False, speed=v0, e2e=True, model_plan_fn=decel_plan(a))
|
||||
results, _ = _run(plant, steps=10, v_cruise=v0)
|
||||
|
||||
assert any(r["dec_mode"] == "blended" for r in results)
|
||||
|
||||
def test_s5_stop_then_depart_with_lead_present_stays_acc_throughout(self):
|
||||
def departing_lead(current_time):
|
||||
return 0.0 if current_time < 1.0 else min(15.0, 3.0 * (current_time - 1.0))
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=0.0, distance_lead=6.0, e2e=True)
|
||||
results = []
|
||||
solver_failures = 0
|
||||
original_reset = plant.planner.mpc.reset
|
||||
|
||||
def counting_reset(*args, **kw):
|
||||
nonlocal solver_failures
|
||||
if plant.planner.mpc.solution_status != 0:
|
||||
solver_failures += 1
|
||||
return original_reset(*args, **kw)
|
||||
|
||||
plant.planner.mpc.reset = counting_reset
|
||||
for _ in range(200):
|
||||
results.append(plant.step(v_lead=departing_lead(plant.current_time), v_cruise=15.0))
|
||||
|
||||
assert solver_failures <= 1
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
assert all(r["dec_lead_veto"] for r in results)
|
||||
|
||||
def test_s6_creep_cycles_behind_lead_stay_acc(self):
|
||||
def creep_cycle_lead(current_time):
|
||||
return 1.5 + 1.5 * np.sin(current_time * 2.0)
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=1.0, distance_lead=8.0, e2e=True, only_radar=True,
|
||||
model_plan_fn=flat_plan(), lead_future_probs_fn=persistent_lead_probs)
|
||||
results = [plant.step(v_lead=creep_cycle_lead(plant.current_time), v_cruise=5.0) for _ in range(200)]
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s7_oscillating_near_threshold_demand_does_not_flap(self):
|
||||
plant = PlantSP(lead_relevancy=False, speed=20.0, e2e=True, model_plan_fn=alternating_plan(-2.5))
|
||||
results, _ = _run(plant, steps=200, v_cruise=20.0)
|
||||
|
||||
assert mode_changes(results) <= 2
|
||||
|
||||
def test_s8_degraded_model_holds_acc_through_a_slowdown(self):
|
||||
def degraded_meta(_current_time):
|
||||
return [0.0] * 5, False, 60.0
|
||||
|
||||
plant = PlantSP(lead_relevancy=False, speed=20.0, e2e=True, model_plan_fn=decel_plan(-3.0), model_meta_fn=degraded_meta)
|
||||
results, _ = _run(plant, steps=30, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s9_curve_exclusion_prevents_false_blend_on_a_bend(self):
|
||||
plant = PlantSP(lead_relevancy=False, speed=20.0, e2e=True, model_plan_fn=decel_plan(-1.0),
|
||||
position_y_fn=lambda _t: [6.0] * len(T_IDXS))
|
||||
results, _ = _run(plant, steps=30, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
|
||||
def test_s11_curve_does_not_interrupt_an_active_hard_stop(self):
|
||||
plant = PlantSP(lead_relevancy=False, speed=20.0, e2e=True, model_plan_fn=decel_plan(-2.5),
|
||||
position_y_fn=lambda _t: [6.0] * len(T_IDXS))
|
||||
results, _ = _run(plant, steps=10, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "blended" for r in results[ENTER_FRAMES - 1:])
|
||||
|
||||
def test_s10_hard_brake_override_inert_while_lead_present(self):
|
||||
def hard_brake_meta(_current_time):
|
||||
return [0.0] * 5, True, 0.0
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=20.0, distance_lead=40.0, e2e=True, only_radar=True,
|
||||
model_plan_fn=flat_plan(), model_meta_fn=hard_brake_meta, lead_future_probs_fn=persistent_lead_probs)
|
||||
results, _ = _run(plant, steps=10, v_lead=20.0, v_cruise=20.0)
|
||||
|
||||
assert all(r["dec_mode"] == "acc" for r in results)
|
||||
@@ -1,164 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
import math
|
||||
from typing import cast
|
||||
|
||||
from openpilot.common.parameterized import parameterized
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.selfdrive.test.longitudinal_maneuvers.plant import Plant
|
||||
from openpilot.sunnypilot.selfdrive.test.longitudinal_maneuvers.plant import PlantSP
|
||||
|
||||
STOCK_STEP_KEYS = ("distance", "speed", "acceleration", "should_stop", "distance_lead", "fcw")
|
||||
|
||||
|
||||
def departing_lead(current_time: float) -> float:
|
||||
return 0.0 if current_time < 1.0 else min(2.0, 2.0 * (current_time - 1.0))
|
||||
|
||||
|
||||
def stopped_lead(_current_time: float) -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
PARITY_SCENARIOS = {
|
||||
"approach_stopped_lead": {"lead_relevancy": True, "speed": 15.0, "distance_lead": 60.0, "v_cruise": 20.0, "v_lead": stopped_lead, "steps": 80},
|
||||
"stop_then_depart": {"lead_relevancy": True, "speed": 0.0, "distance_lead": 6.0, "v_cruise": 8.0, "v_lead": departing_lead, "steps": 120},
|
||||
}
|
||||
|
||||
|
||||
def _drive(cls, *, v_cruise: float, v_lead: Callable[[float], float], steps: int, **kwargs):
|
||||
plant = cls(**kwargs)
|
||||
plant.v_lead_prev = v_lead(0.0)
|
||||
solver_failures = 0
|
||||
original_reset = plant.planner.mpc.reset
|
||||
|
||||
def counting_reset(*args, **kw):
|
||||
nonlocal solver_failures
|
||||
if plant.planner.mpc.solution_status != 0:
|
||||
solver_failures += 1
|
||||
return original_reset(*args, **kw)
|
||||
|
||||
plant.planner.mpc.reset = counting_reset
|
||||
results = []
|
||||
for _ in range(steps):
|
||||
lead_speed = v_lead(plant.current_time)
|
||||
result = plant.step(v_lead=lead_speed, v_cruise=v_cruise)
|
||||
results.append((result, plant.planner.mpc.source, plant.planner.output_a_target))
|
||||
return results, solver_failures
|
||||
|
||||
|
||||
class TestPlantSP(OpenpilotTestCase):
|
||||
@parameterized.expand(PARITY_SCENARIOS, names=("scenario",), ids=lambda scenario: scenario)
|
||||
def test_plant_sp_matches_stock_plant_on_shared_kwargs(self, scenario: str):
|
||||
kwargs = dict(PARITY_SCENARIOS[scenario])
|
||||
v_cruise = cast(float, kwargs.pop("v_cruise"))
|
||||
v_lead = cast(Callable[[float], float], kwargs.pop("v_lead"))
|
||||
steps = cast(int, kwargs.pop("steps"))
|
||||
|
||||
stock_results, stock_failures = _drive(Plant, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
|
||||
sp_results, sp_failures = _drive(PlantSP, v_cruise=v_cruise, v_lead=v_lead, steps=steps, **kwargs)
|
||||
|
||||
assert stock_failures == 0, f"stock Plant solver failed {stock_failures} times in {scenario!r}"
|
||||
assert sp_failures == 0, f"PlantSP solver failed {sp_failures} times in {scenario!r}"
|
||||
|
||||
for frame, ((stock_result, stock_source, stock_a_target), (sp_result, sp_source, sp_a_target)) in enumerate(
|
||||
zip(stock_results, sp_results, strict=True),
|
||||
):
|
||||
for key in STOCK_STEP_KEYS:
|
||||
if isinstance(stock_result[key], float):
|
||||
self.assertAlmostEqual(sp_result[key], stock_result[key], msg=f"{scenario} frame {frame} key {key}")
|
||||
else:
|
||||
assert sp_result[key] == stock_result[key], f"{scenario} frame {frame} key {key}"
|
||||
assert sp_source == stock_source, f"{scenario} frame {frame} mpc.source"
|
||||
self.assertAlmostEqual(sp_a_target, stock_a_target, msg=f"{scenario} frame {frame} output_a_target")
|
||||
|
||||
if scenario == "stop_then_depart":
|
||||
departure_frame = round(1.0 / DT_MDL)
|
||||
for results in (stock_results, sp_results):
|
||||
assert all(result["speed"] < 0.01 for result, _, _ in results[:departure_frame])
|
||||
assert results[departure_frame - 1][0]["should_stop"]
|
||||
assert any(not result["should_stop"] for result, _, _ in results[departure_frame:])
|
||||
assert any(result["speed"] > 0.05 for result, _, _ in results[departure_frame:])
|
||||
stock_release = next(frame for frame, (result, _, _) in enumerate(stock_results)
|
||||
if frame >= departure_frame and not result["should_stop"])
|
||||
sp_release = next(frame for frame, (result, _, _) in enumerate(sp_results)
|
||||
if frame >= departure_frame and not result["should_stop"])
|
||||
stock_motion = next(frame for frame, (result, _, _) in enumerate(stock_results)
|
||||
if frame >= departure_frame and result["speed"] > 0.05)
|
||||
sp_motion = next(frame for frame, (result, _, _) in enumerate(sp_results)
|
||||
if frame >= departure_frame and result["speed"] > 0.05)
|
||||
assert sp_release == stock_release
|
||||
assert sp_motion == stock_motion
|
||||
|
||||
def test_full_lead_observation_is_independent_from_truth(self):
|
||||
callback_inputs = []
|
||||
|
||||
def observe_lead(current_time, lead_name, truth):
|
||||
callback_inputs.append((current_time, lead_name, truth))
|
||||
if lead_name == "leadOne":
|
||||
return {
|
||||
"dRel": 12.5,
|
||||
"vRel": -4.0,
|
||||
"vLead": 6.0,
|
||||
"vLeadK": 5.5,
|
||||
"aLeadK": -1.25,
|
||||
"aLeadTau": 0.7,
|
||||
"present": True,
|
||||
"modelProb": 0.9,
|
||||
"radarTrackId": 42,
|
||||
}
|
||||
return None
|
||||
|
||||
plant = PlantSP(lead_relevancy=True, speed=10.0, distance_lead=50.0, lead_observation_fn=observe_lead)
|
||||
result = plant.step(v_lead=8.0)
|
||||
|
||||
assert [entry[1] for entry in callback_inputs] == ["leadOne", "leadTwo"]
|
||||
self.assertAlmostEqual(callback_inputs[0][2]["dRel"], 50.0)
|
||||
self.assertAlmostEqual(result["truth_lead"]["dRel"], 50.0)
|
||||
self.assertAlmostEqual(result["lead_one_observation"]["dRel"], 12.5)
|
||||
assert result["lead_one_observation"]["radarTrackId"] == 42
|
||||
assert result["lead_two_observation"] is None
|
||||
self.assertAlmostEqual(result["distance_lead"], 50.0 + 8.0 * DT_MDL)
|
||||
|
||||
def test_model_action_realized_acceleration_and_source_logging(self):
|
||||
def model_action(current_time, v_ego, a_ego):
|
||||
return -1.25, True
|
||||
|
||||
plant = PlantSP(speed=10.0, e2e=True, force_decel=True, model_action_fn=model_action, actuator_lag=0.5)
|
||||
first = plant.step()
|
||||
second = plant.step()
|
||||
|
||||
assert first["model_action"] == {"desiredAcceleration": -1.25, "shouldStop": True}
|
||||
self.assertAlmostEqual(first["published_a_ego"], 0.0)
|
||||
self.assertAlmostEqual(second["published_a_ego"], first["realized_acceleration"])
|
||||
assert first["acceleration"] == first["realized_acceleration"]
|
||||
assert abs(first["realized_acceleration"]) < abs(first["actuator_command"])
|
||||
assert first["mpc_source"] is not None
|
||||
assert first["dec_mode"] in ("acc", "blended")
|
||||
assert "controller_active" in first
|
||||
assert first["lead_one_observation"] is not None
|
||||
assert first["truth_lead"] == first["lead_one_observation"]
|
||||
|
||||
def test_default_model_action_matches_stock_plant(self):
|
||||
result = PlantSP(speed=10.0).step()
|
||||
|
||||
self.assertAlmostEqual(result["model_action"]["desiredAcceleration"], 0.5)
|
||||
assert not result["model_action"]["shouldStop"]
|
||||
|
||||
def test_configurable_transport_delay_and_first_order_lag(self):
|
||||
plant = PlantSP(speed=10.0, actuator_delay=2 * DT_MDL, actuator_lag=0.2)
|
||||
|
||||
self.assertAlmostEqual(plant.planner.CP.longitudinalActuatorDelay, 2 * DT_MDL)
|
||||
delayed_commands = [plant._update_actuator(-1.0) for _ in range(3)]
|
||||
assert [command for command, _ in delayed_commands[:2]] == [0.0, 0.0]
|
||||
|
||||
expected_acceleration = -(1.0 - math.exp(-DT_MDL / 0.2))
|
||||
assert delayed_commands[2][0] == -1.0
|
||||
self.assertAlmostEqual(delayed_commands[2][1], expected_acceleration)
|
||||
|
||||
@parameterized.expand(
|
||||
[(-0.1, 0.0), (float("nan"), 0.0), (float("inf"), 0.0), (None, -0.1), (None, float("nan")), (None, float("inf"))],
|
||||
names=("delay", "lag"),
|
||||
)
|
||||
def test_invalid_actuator_dynamics(self, delay, lag):
|
||||
with self.assertRaises(ValueError):
|
||||
PlantSP(actuator_delay=delay, actuator_lag=lag)
|
||||
@@ -652,53 +652,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelPersonalityEnabled",
|
||||
"widget": "toggle",
|
||||
"title": "Enable Accel Controller",
|
||||
"description": "Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency braking and stopping are unchanged.",
|
||||
"visibility": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "AccelPersonality",
|
||||
"widget": "multiple_button",
|
||||
"title": "Acceleration Profile",
|
||||
"description": "Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive.",
|
||||
"options": [
|
||||
{
|
||||
"value": 0,
|
||||
"label": "Eco"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Normal"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Sport"
|
||||
}
|
||||
],
|
||||
"enablement": [
|
||||
{
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "IntelligentCruiseButtonManagement",
|
||||
"widget": "toggle",
|
||||
@@ -1652,18 +1605,6 @@
|
||||
"widget": "toggle",
|
||||
"title": "Use Metric System",
|
||||
"description": "Display speed in km/h instead of mph."
|
||||
},
|
||||
{
|
||||
"key": "SunnypilotStoppingController",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Stopping Controller",
|
||||
"description": "Take over the end of a stop. The request follows the speed down to the wheel stop, then holds the car at a standstill.",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "offroad_only"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2361,61 +2302,6 @@
|
||||
"title": "Toyota / Lexus Settings",
|
||||
"description": "",
|
||||
"items": [
|
||||
{
|
||||
"key": "ToyotaAutoHold",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaBrakeOnset",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: soft brake onset, overshoot limit and regen hand-over",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaEnhancedBsm",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: Prius TSS2 BSM and some tssp",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaTSS2Long",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Toyota: custom longitudinal for TSS2",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaDriveMode",
|
||||
"widget": "toggle",
|
||||
"needs_onroad_cycle": true,
|
||||
"title": "Enable drive mode btn link",
|
||||
"enablement": [
|
||||
{
|
||||
"type": "not_engaged"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "ToyotaEnforceStockLongitudinal",
|
||||
"widget": "toggle",
|
||||
|
||||
@@ -43,28 +43,6 @@ sections:
|
||||
label: Relaxed
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonalityEnabled
|
||||
widget: toggle
|
||||
title: Enable Accel Controller
|
||||
description: Lets you choose how sunnypilot starts, catches up, and settles at the cruise speed. Emergency braking and stopping are
|
||||
unchanged.
|
||||
visibility:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: AccelPersonality
|
||||
widget: multiple_button
|
||||
title: Acceleration Profile
|
||||
description: Eco is gentlest, Normal balances a prompt start with smooth catch-up, and Sport is more responsive.
|
||||
options:
|
||||
- value: 0
|
||||
label: Eco
|
||||
- value: 1
|
||||
label: Normal
|
||||
- value: 2
|
||||
label: Sport
|
||||
enablement:
|
||||
- $ref: '#/macros/longitudinal'
|
||||
- key: IntelligentCruiseButtonManagement
|
||||
widget: toggle
|
||||
title: Intelligent Cruise Button Management (ICBM) (Alpha)
|
||||
|
||||
@@ -32,14 +32,6 @@ sections:
|
||||
widget: toggle
|
||||
title: Use Metric System
|
||||
description: Display speed in km/h instead of mph.
|
||||
- key: SunnypilotStoppingController
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Stopping Controller
|
||||
description: Take over the end of a stop. The request follows the speed down to the wheel stop, then holds the car at a
|
||||
standstill.
|
||||
enablement:
|
||||
- $ref: '#/macros/offroad'
|
||||
- id: recording
|
||||
title: Recording
|
||||
description: Camera and audio recording during drives
|
||||
|
||||
@@ -82,36 +82,6 @@ sections:
|
||||
title: Toyota / Lexus Settings
|
||||
description: ''
|
||||
items:
|
||||
- key: ToyotaAutoHold
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: Auto Brake Hold FOR TSS2 HYBRID CARS'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaBrakeOnset
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: soft brake onset, overshoot limit and regen hand-over'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaEnhancedBsm
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: Prius TSS2 BSM and some tssp'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaTSS2Long
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: 'Toyota: custom longitudinal for TSS2'
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaDriveMode
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
title: Enable drive mode btn link
|
||||
enablement:
|
||||
- $ref: '#/macros/not_engaged'
|
||||
- key: ToyotaEnforceStockLongitudinal
|
||||
widget: toggle
|
||||
needs_onroad_cycle: true
|
||||
|
||||
@@ -10,10 +10,9 @@ change and must be intentional. KNOWN_PROTOCOL_VERSIONS pins the set we
|
||||
explicitly support — when the constant is bumped, this list must be edited in
|
||||
the same commit so the bump shows up in code review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
from openpilot.sunnypilot.sunnylink.capabilities import (
|
||||
CAPABILITY_DEFAULTS,
|
||||
CAPABILITY_FIELDS,
|
||||
@@ -21,23 +20,13 @@ from openpilot.sunnypilot.sunnylink.capabilities import (
|
||||
PROTOCOL_VERSION,
|
||||
generate_capabilities,
|
||||
)
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
|
||||
KNOWN_PROTOCOL_VERSIONS = (1,)
|
||||
LATEST_KNOWN = max(KNOWN_PROTOCOL_VERSIONS)
|
||||
|
||||
|
||||
class FakeParams:
|
||||
def __init__(self, values=None):
|
||||
self.values = values or {}
|
||||
|
||||
def get(self, key, *args, **kwargs):
|
||||
return self.values.get(key)
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.values.get(key, False))
|
||||
|
||||
|
||||
def caps():
|
||||
return generate_capabilities()
|
||||
|
||||
@@ -63,12 +52,14 @@ class TestProtocolVersion(OpenpilotTestCase):
|
||||
def test_protocol_version_is_known(self):
|
||||
"""Sentinel against accidental bumps. Edit KNOWN_PROTOCOL_VERSIONS if intentional."""
|
||||
assert PROTOCOL_VERSION in KNOWN_PROTOCOL_VERSIONS, (
|
||||
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. "
|
||||
+ "If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
|
||||
f"PROTOCOL_VERSION={PROTOCOL_VERSION} is not in KNOWN_PROTOCOL_VERSIONS={KNOWN_PROTOCOL_VERSIONS}. " +
|
||||
"If this bump is intentional, add it to KNOWN_PROTOCOL_VERSIONS."
|
||||
)
|
||||
|
||||
def test_protocol_version_matches_latest_known(self):
|
||||
assert PROTOCOL_VERSION == LATEST_KNOWN, "Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
|
||||
assert PROTOCOL_VERSION == LATEST_KNOWN, (
|
||||
"Test invariant: PROTOCOL_VERSION must equal max(KNOWN_PROTOCOL_VERSIONS)."
|
||||
)
|
||||
|
||||
|
||||
class TestOpaquePerBrandFlags(OpenpilotTestCase):
|
||||
|
||||
@@ -9,7 +9,6 @@ isolates one of the gating bugs that the design-overhaul branch fixes so a
|
||||
future regression is loud and obvious. These tests are intentionally narrow
|
||||
and additive — they do not replace the broader test_settings_schema.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -25,13 +24,14 @@ from openpilot.sunnypilot.sunnylink.tools.generate_settings_schema import (
|
||||
_load_torque_versions,
|
||||
generate_schema,
|
||||
)
|
||||
from openpilot.sunnypilot.sunnylink.tools.validate_settings_ui import validate as validate_settings_ui
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
|
||||
SCHEMA_VALIDATOR_PATH = os.path.join(os.path.dirname(DEFINITION_PATH), "settings_ui.schema.json")
|
||||
|
||||
|
||||
def _walk_items(schema: dict[str, Any]):
|
||||
"""Yield every item dict from the schema."""
|
||||
|
||||
def _yield(item: dict[str, Any]):
|
||||
yield item
|
||||
for sub in item.get("sub_items", []):
|
||||
@@ -149,13 +149,22 @@ class TestTestManeuversSection(OpenpilotTestCase):
|
||||
assert "is_sp_release" in vis_refs
|
||||
enablement = section.get("enablement") or []
|
||||
enable_refs = json.dumps(enablement)
|
||||
assert "ShowAdvancedControls" in enable_refs, "test_maneuvers must gate ShowAdvancedControls via enablement"
|
||||
assert "ShowAdvancedControls" in enable_refs, \
|
||||
"test_maneuvers must gate ShowAdvancedControls via enablement"
|
||||
|
||||
|
||||
class TestValidator(OpenpilotTestCase):
|
||||
def test_validator_accepts_real_json(self):
|
||||
"""settings_ui.json passes the repository's production schema validator."""
|
||||
self.assertTrue(validate_settings_ui(DEFINITION_PATH))
|
||||
"""settings_ui.json validates against settings_ui.schema.json."""
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
with open(DEFINITION_PATH) as f:
|
||||
data = json.load(f)
|
||||
with open(SCHEMA_VALIDATOR_PATH) as f:
|
||||
validator = json.load(f)
|
||||
jsonschema.validate(instance=data, schema=validator)
|
||||
|
||||
|
||||
class TestTorqueOptionGeneration(OpenpilotTestCase):
|
||||
@@ -168,17 +177,16 @@ class TestTorqueOptionGeneration(OpenpilotTestCase):
|
||||
assert item.get("options") == expected
|
||||
|
||||
def test_torque_versions_path_resolves(self):
|
||||
assert os.path.exists(TORQUE_VERSIONS_PATH), f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
|
||||
assert os.path.exists(TORQUE_VERSIONS_PATH), (
|
||||
f"latcontrol_torque_versions.json not found at {TORQUE_VERSIONS_PATH}"
|
||||
)
|
||||
|
||||
|
||||
class TestReleaseBranchGates(OpenpilotTestCase):
|
||||
@parameterized.expand(
|
||||
[
|
||||
"EnableGithubRunner",
|
||||
"QuickBootToggle",
|
||||
],
|
||||
names=["key"],
|
||||
)
|
||||
@parameterized.expand([
|
||||
"EnableGithubRunner",
|
||||
"QuickBootToggle",
|
||||
], names=["key"])
|
||||
def test_sp_dev_items_gate_on_is_sp_release(self, schema, key):
|
||||
"""sunnypilot dev items must hide on sunnypilot release branches (is_sp_release gate)."""
|
||||
item = _find_item(schema, key)
|
||||
@@ -200,14 +208,11 @@ class TestSpuriousOffroadGatesDropped(OpenpilotTestCase):
|
||||
|
||||
|
||||
class TestNotEngagedReplacement(OpenpilotTestCase):
|
||||
@parameterized.expand(
|
||||
[
|
||||
"AlphaLongitudinalEnabled",
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
],
|
||||
names=["key"],
|
||||
)
|
||||
@parameterized.expand([
|
||||
"AlphaLongitudinalEnabled",
|
||||
"ToyotaEnforceStockLongitudinal",
|
||||
"ToyotaStopAndGoHack",
|
||||
], names=["key"])
|
||||
def test_offroad_only_replaced_with_not_engaged(self, schema, key):
|
||||
"""These items should use not_engaged, not offroad_only."""
|
||||
item = _find_item(schema, key)
|
||||
@@ -215,5 +220,3 @@ class TestNotEngagedReplacement(OpenpilotTestCase):
|
||||
rule_types = _flatten_rule_types(item.get("enablement"))
|
||||
assert "offroad_only" not in rule_types, f"{key} still uses offroad_only"
|
||||
assert "not_engaged" in rule_types, f"{key} missing not_engaged"
|
||||
|
||||
|
||||
|
||||
@@ -276,36 +276,13 @@ class TestKnownPanels(OpenpilotTestCase):
|
||||
enhanced_enable_keys = {r.get("key") for r in enhanced.get("enablement", []) if r.get("type") == "param"}
|
||||
assert "NeuralNetworkLateralControl" in enhanced_enable_keys
|
||||
|
||||
def test_accel_controller_profile_mapping_and_enablement(self, schema):
|
||||
cruise = next(p for p in schema["panels"] if p["id"] == "cruise")
|
||||
items = {item["key"]: item for item in _iter_panel_items(cruise)}
|
||||
|
||||
assert items["AccelPersonalityEnabled"]["widget"] == "toggle"
|
||||
assert items["AccelPersonality"]["options"] == [
|
||||
{"value": 0, "label": "Eco"},
|
||||
{"value": 1, "label": "Normal"},
|
||||
{"value": 2, "label": "Sport"},
|
||||
]
|
||||
assert {
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": True,
|
||||
} in items["AccelPersonalityEnabled"]["enablement"]
|
||||
assert {
|
||||
"type": "capability",
|
||||
"field": "has_longitudinal_control",
|
||||
"equals": True,
|
||||
} in items["AccelPersonality"]["enablement"]
|
||||
profile_enable_keys = {rule.get("key") for rule in items["AccelPersonality"]["enablement"] if rule.get("type") == "param"}
|
||||
assert "AccelPersonalityEnabled" not in profile_enable_keys
|
||||
|
||||
|
||||
class TestKnownVehicleSettings(OpenpilotTestCase):
|
||||
def test_hyundai_has_longitudinal_tuning(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("hyundai"))}
|
||||
assert "HyundaiLongitudinalTuning" in keys
|
||||
|
||||
def test_toyota_has_enforce_stock_stop_go(self, schema):
|
||||
def test_toyota_has_enforce_stock_and_stop_go(self, schema):
|
||||
keys = {i["key"] for i in _brand_items(schema["vehicle_settings"].get("toyota"))}
|
||||
assert "ToyotaEnforceStockLongitudinal" in keys
|
||||
assert "ToyotaStopAndGoHack" in keys
|
||||
|
||||
@@ -170,6 +170,7 @@ procs += [
|
||||
# Models
|
||||
PythonProcess("models_manager", "openpilot.sunnypilot.models.manager", only_offroad),
|
||||
NativeProcess("modeld_tinygrad", "openpilot/sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)),
|
||||
PythonProcess("modeld_warmup", "openpilot.sunnypilot.modeld_v2.warmup", only_offroad),
|
||||
|
||||
# Backup
|
||||
PythonProcess("backup_manager", "openpilot.sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)),
|
||||
|
||||
@@ -45,9 +45,8 @@ class ScrollState(Enum):
|
||||
|
||||
|
||||
class GuiScrollPanel2:
|
||||
def __init__(self, horizontal: bool = True, handle_out_of_bounds: bool = True) -> None:
|
||||
def __init__(self, horizontal: bool = True) -> None:
|
||||
self._horizontal = horizontal
|
||||
self._handle_out_of_bounds = handle_out_of_bounds
|
||||
self._state = ScrollState.STEADY
|
||||
self._offset: rl.Vector2 = rl.Vector2(0, 0)
|
||||
self._initial_click_event: MouseEvent | None = None
|
||||
@@ -86,20 +85,6 @@ class GuiScrollPanel2:
|
||||
"""Returns (max_offset, min_offset) for the given bounds and content size."""
|
||||
return 0.0, min(0.0, bounds_size - content_size)
|
||||
|
||||
def _clamp_offset(self, bounds_size: float, content_size: float) -> None:
|
||||
if self._handle_out_of_bounds:
|
||||
return
|
||||
|
||||
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
|
||||
offset = self.get_offset()
|
||||
clamped_offset = max(min_offset, min(max_offset, offset))
|
||||
if clamped_offset == offset:
|
||||
return
|
||||
|
||||
self.set_offset(clamped_offset)
|
||||
if (clamped_offset == max_offset and self._velocity > 0) or (clamped_offset == min_offset and self._velocity < 0):
|
||||
self._velocity = 0.0
|
||||
|
||||
def _update_state(self, bounds_size: float, content_size: float, snap_target: float | None) -> None:
|
||||
"""Runs per render frame, independent of mouse events. Updates auto-scrolling state and velocity."""
|
||||
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
|
||||
@@ -153,8 +138,6 @@ class GuiScrollPanel2:
|
||||
factor = 1.0 - math.exp(-SNAP_RATE * dt)
|
||||
self.set_offset(self.get_offset() + dist * factor)
|
||||
|
||||
self._clamp_offset(bounds_size, content_size)
|
||||
|
||||
def _handle_mouse_event(self, mouse_event: MouseEvent, bounds: rl.Rectangle, bounds_size: float,
|
||||
content_size: float) -> None:
|
||||
max_offset, min_offset = self._get_offset_bounds(bounds_size, content_size)
|
||||
|
||||
@@ -75,6 +75,7 @@ class _Scroller(Widget):
|
||||
self._items: list[Widget] = []
|
||||
self._horizontal = horizontal
|
||||
self._snap_items = snap_items
|
||||
assert not self._snap_items or self._horizontal, "Snapping is only supported for horizontal scrolling"
|
||||
self._spacing = spacing
|
||||
self._pad = pad
|
||||
|
||||
@@ -190,20 +191,12 @@ class _Scroller(Widget):
|
||||
snap_target: float | None = None
|
||||
if self._snap_items and visible_items and self._scrolling_to[0] is None:
|
||||
# TODO: this doesn't handle two small buttons at the edges well
|
||||
center_pos = (self._rect.x + self._rect.width / 2) if self._horizontal else (self._rect.y + self._rect.height / 2)
|
||||
closest_delta_pos = min(
|
||||
(self._item_center_pos(item) - center_pos for item in visible_items),
|
||||
key=abs,
|
||||
)
|
||||
center_pos = self._rect.x + self._rect.width / 2
|
||||
closest_delta_pos = min((((item.rect.x + item.rect.width / 2) - center_pos) for item in visible_items), key=abs)
|
||||
snap_target = self.scroll_panel.get_offset() - closest_delta_pos
|
||||
|
||||
return self.scroll_panel.update(self._rect, content_size, snap_target=snap_target)
|
||||
|
||||
def _item_center_pos(self, item: Widget) -> float:
|
||||
if self._horizontal:
|
||||
return item.rect.x + item.rect.width / 2
|
||||
return item.rect.y + item.rect.height / 2
|
||||
|
||||
@property
|
||||
def moving_items(self) -> bool:
|
||||
return len(self._move_animations) > 0 or len(self._move_lift) > 0
|
||||
|
||||
@@ -54,18 +54,10 @@ def create_pkl_name(full_name: str) -> str:
|
||||
|
||||
|
||||
def _hash_pkl(pkl_path: Path) -> str:
|
||||
manifest = Path(f"{pkl_path}.chunkmanifest")
|
||||
if manifest.exists():
|
||||
num_chunks = int(manifest.read_text().strip())
|
||||
paths = [Path(f"{pkl_path}.chunk{i + 1:02d}of{num_chunks:02d}") for i in range(num_chunks)]
|
||||
else:
|
||||
paths = [pkl_path]
|
||||
|
||||
digest = hashlib.sha256()
|
||||
for path in paths:
|
||||
with path.open('rb') as f:
|
||||
while block := f.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
with pkl_path.open('rb') as f:
|
||||
while block := f.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@@ -74,22 +66,9 @@ def _find_driving_pkl(output_path: Path) -> Path | None:
|
||||
matches = sorted(output_path.glob(pattern))
|
||||
if matches:
|
||||
return matches[0]
|
||||
for pattern in ('*driving_tinygrad.pkl.chunkmanifest', '*driving_*_tinygrad.pkl.chunkmanifest'):
|
||||
matches = sorted(output_path.glob(pattern))
|
||||
if matches:
|
||||
return Path(str(matches[0]).removesuffix('.chunkmanifest'))
|
||||
return None
|
||||
|
||||
|
||||
def _rename_pkl_with_chunks(old_pkl: Path, new_pkl: Path) -> Path:
|
||||
manifest = Path(f"{old_pkl}.chunkmanifest")
|
||||
if manifest.exists():
|
||||
for f in sorted(old_pkl.parent.glob(f"{old_pkl.name}.chunk*")):
|
||||
f.rename(old_pkl.parent / f.name.replace(old_pkl.name, new_pkl.name, 1))
|
||||
return new_pkl
|
||||
return old_pkl.rename(new_pkl)
|
||||
|
||||
|
||||
def _hash_onnx_files(model_dir: Path) -> str | None:
|
||||
onnx_files = [f for f in sorted(model_dir.glob("*.onnx")) if f.stat().st_size > 1024]
|
||||
if not onnx_files:
|
||||
@@ -102,22 +81,9 @@ def _hash_onnx_files(model_dir: Path) -> str | None:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def generate_chunked_model(driving_pkl: Path) -> dict:
|
||||
def generate_model(driving_pkl: Path) -> dict:
|
||||
tinygrad_hash = _hash_pkl(driving_pkl)
|
||||
|
||||
chunks_config = []
|
||||
manifest_file = Path(f"{driving_pkl}.chunkmanifest")
|
||||
if manifest_file.exists():
|
||||
num_chunks = int(manifest_file.read_text().strip())
|
||||
for i in range(num_chunks):
|
||||
chunk_path = Path(f"{driving_pkl}.chunk{i + 1:02d}of{num_chunks:02d}")
|
||||
if chunk_path.exists():
|
||||
chunk_hash = hashlib.sha256(chunk_path.read_bytes()).hexdigest()
|
||||
chunks_config.append({
|
||||
"file_name": chunk_path.name,
|
||||
"sha256": chunk_hash
|
||||
})
|
||||
|
||||
artifact_data = {
|
||||
"file_name": driving_pkl.name,
|
||||
"download_uri": {
|
||||
@@ -126,9 +92,6 @@ def generate_chunked_model(driving_pkl: Path) -> dict:
|
||||
}
|
||||
}
|
||||
|
||||
if chunks_config:
|
||||
artifact_data["chunks"] = chunks_config
|
||||
|
||||
return {
|
||||
"type": "chunked",
|
||||
"artifact": artifact_data,
|
||||
@@ -193,11 +156,11 @@ if __name__ == "__main__":
|
||||
if _pkl:
|
||||
new_pkl = _output_dir / f"driving_{_pkl}_tinygrad.pkl"
|
||||
if not new_pkl.exists():
|
||||
_driving_pkl = _rename_pkl_with_chunks(_driving_pkl, new_pkl)
|
||||
_driving_pkl = _driving_pkl.rename(new_pkl)
|
||||
else:
|
||||
_driving_pkl = new_pkl
|
||||
|
||||
_model_metadata = generate_chunked_model(_driving_pkl)
|
||||
_model_metadata = generate_model(_driving_pkl)
|
||||
_onnx_sha256 = args.onnx_sha256 or _hash_onnx_files(Path(args.model_dir))
|
||||
create_metadata_json([_model_metadata], _output_dir, args.custom_name, _short_name, args.is_20hz, args.upstream_branch,
|
||||
onnx_sha256=_onnx_sha256, is_big=is_big)
|
||||
|
||||
@@ -79,8 +79,6 @@ def main():
|
||||
artifact = bundle['models'][0]['artifact']
|
||||
hf_base = f"https://huggingface.co/datasets/{args.hf_repo}/resolve/main/{args.hf_defaults_path}/{folder_name}"
|
||||
artifact['download_uri']['url'] = f"{hf_base}/{artifact['file_name']}"
|
||||
for chunk in artifact.get('chunks', []):
|
||||
chunk['url'] = f"{hf_base}/{chunk['file_name']}"
|
||||
|
||||
print(f"Uploading model to {args.hf_defaults_path}/{folder_name}/")
|
||||
api.upload_folder(
|
||||
|
||||
+1
-1
Submodule tinygrad_repo updated: fe5d3169ba...69fd4d34d4
Reference in New Issue
Block a user