mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-18 03:23:45 +08:00
Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a3550358e | |||
| 7df636ac01 | |||
| ca0202c972 | |||
| 35e78a9d95 | |||
| 27c132984d | |||
| 0d57eadd74 | |||
| f20d9ea5e6 | |||
| 5ec0c02195 | |||
| 90932d9b5c | |||
| fcf4b0d395 | |||
| f5bcd10a2a | |||
| cd3d5b0057 | |||
| 4429ec0c8c | |||
| 41acce17fa | |||
| f7bc31f208 | |||
| 75b2ccca60 | |||
| f50108b4af | |||
| fe7ccf9587 | |||
| 24c26487f4 | |||
| 6eb63e0454 | |||
| 690fa743b9 | |||
| 4f3626dc9b | |||
| 1e219d7785 | |||
| 6939d172e8 | |||
| 66752e769f | |||
| 21edbbcfb9 | |||
| 2b09bc94a9 | |||
| cb1d6f11a7 | |||
| d3f0aab96f | |||
| 50319fa702 | |||
| 9bebe42f73 | |||
| 1623441a9f | |||
| 675d38213e | |||
| a47d1c56f6 | |||
| 4c53557764 | |||
| afcbb171f6 | |||
| 42ccc5cebd | |||
| 2713630e79 | |||
| 4c362ca327 | |||
| 6f4c0faf1f | |||
| 64be6c80d3 | |||
| 0679ff36c6 | |||
| b9c815d56a | |||
| 263e8d1966 | |||
| 655bfdebc7 | |||
| 0e5718116a | |||
| 043759da36 | |||
| d05c2d971b | |||
| 9b585d58f5 | |||
| 948ad05c6c | |||
| f804d3468e | |||
| 6846d733d4 | |||
| 28917bb3ab | |||
| 8d1865bdc6 | |||
| a742df6182 | |||
| f89ce23f9d | |||
| 3f06ed3bc7 | |||
| f21bfc36be | |||
| 657d3a4f7c | |||
| f59056e5b6 | |||
| 0e0c7c711d | |||
| a0d47bcd08 | |||
| 035a447d1f | |||
| 5b2a59d288 | |||
| ab8c84c398 | |||
| c933446486 | |||
| 2b7c34a5c3 | |||
| fc61640782 | |||
| 84f9bdbc10 | |||
| cce54f20d5 | |||
| 4bcf73282e | |||
| 910c08e082 | |||
| 01a670d6b2 | |||
| ec95db3f1f | |||
| 78dccf0b48 | |||
| 7ee974ab5b | |||
| d2c0ce91ad | |||
| c8fb906815 | |||
| 0c464e5c66 | |||
| 134517ceb3 | |||
| 49bbba371c | |||
| f474f05928 | |||
| b78331e042 | |||
| 46b97e6195 | |||
| 027770d49c | |||
| bbabd94c11 | |||
| f2c772ff11 | |||
| afa47035ba | |||
| c5543b1a05 | |||
| 4dbdd66e0c | |||
| f00d226d39 | |||
| a86351c3ef | |||
| 8377c40da1 | |||
| 841076f6d1 | |||
| 27fa4fca8e | |||
| 93eec90077 | |||
| e1862e2e17 | |||
| ec9241f296 | |||
| 66fd3d1bd4 | |||
| 082c43da39 | |||
| 521db4c825 | |||
| 3b2a75a4e0 | |||
| 1b1b60ba93 | |||
| 95ed0213ec | |||
| ea0afb67a8 | |||
| 680aa6f868 | |||
| d375e7ff5d | |||
| 2eee697f7d | |||
| 5ae0da0e46 | |||
| a39561058f | |||
| 8abd133637 | |||
| e691acbf34 | |||
| 7f6f13997c | |||
| d06711be19 | |||
| 9a95fdc1a2 | |||
| c6d13eb723 | |||
| c8d56a1990 | |||
| df7e0e5e77 | |||
| 6c69ebed7f | |||
| c504b92aba | |||
| 1090e2824b | |||
| b55e37b9c9 | |||
| e77f6e56f9 | |||
| 60b3ccf39e | |||
| bca3b04720 | |||
| 7635dec618 | |||
| 3cd1b6e483 | |||
| 7dcb524058 | |||
| d643860afa | |||
| f992a60bbd | |||
| 1328ace068 | |||
| 7db7735598 | |||
| cd1490af4b | |||
| bd176cb6a7 | |||
| 4d9d1bc341 | |||
| cab53438ae |
@@ -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)
|
||||
|
||||
@@ -185,7 +171,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
lfs: true
|
||||
|
||||
- name: Pull big PKL via LFS
|
||||
run: git lfs pull -I "${{ env.BIG_PKL }}"
|
||||
@@ -221,19 +206,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 +245,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 +256,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 +335,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 +360,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 +381,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 +444,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 +476,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 +490,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
|
||||
|
||||
Vendored
+51
-9
@@ -161,6 +161,14 @@ def step(String name, String cmd, Map args = [:]) {
|
||||
return [name, cmd, args]
|
||||
}
|
||||
|
||||
def chestnutStage(String name = "chestnut") {
|
||||
deviceStage(name, "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [
|
||||
step("build", "./openpilot/selfdrive/test/chestnut.sh"),
|
||||
step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"),
|
||||
step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]),
|
||||
])
|
||||
}
|
||||
|
||||
node {
|
||||
env.CI = "1"
|
||||
env.PYTHONWARNINGS = "error"
|
||||
@@ -171,6 +179,36 @@ node {
|
||||
env.GIT_BRANCH = checkout(scm).GIT_BRANCH
|
||||
env.GIT_COMMIT = checkout(scm).GIT_COMMIT
|
||||
|
||||
if (env.JOB_BASE_NAME == 'chestnut-stresstest') {
|
||||
sh 'git fetch --no-tags origin nightly-chestnut'
|
||||
env.GIT_COMMIT = sh(script: 'git show FETCH_HEAD:git_src_commit', returnStdout: true).trim()
|
||||
if (!(env.GIT_COMMIT ==~ /[0-9a-f]{40}/)) {
|
||||
error("invalid nightly-chestnut source commit")
|
||||
}
|
||||
def results = []
|
||||
try {
|
||||
timeout(time: 12, unit: 'HOURS') {
|
||||
for (int run = 1; run <= 100; run++) {
|
||||
results << "run ${run}: INCOMPLETE"
|
||||
try {
|
||||
chestnutStage("chestnut ${run}/100")
|
||||
results[run - 1] = "run ${run}: PASS"
|
||||
} catch (hudson.AbortException e) {
|
||||
results[run - 1] = "run ${run}: FAIL"
|
||||
echo e.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
writeFile file: 'chestnut_stress_report.txt', text: "commit: ${env.GIT_COMMIT}\ncompleted: ${results.count { !it.endsWith('INCOMPLETE') }}/100\n" + results.join('\n') + '\n'
|
||||
archiveArtifacts artifacts: 'chestnut_stress_report.txt'
|
||||
}
|
||||
if (results.any { !it.endsWith('PASS') }) {
|
||||
error("chestnut stress test failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
def excludeBranches = ['__nightly', '__nightly-chestnut', 'devel', 'devel-staging',
|
||||
'release-tizi', 'release-tizi-staging', 'release-mici', 'release-mici-staging', 'testing-closet*', 'hotfix-*']
|
||||
def excludeRegex = excludeBranches.join('|').replaceAll('\\*', '.*')
|
||||
@@ -204,9 +242,18 @@ node {
|
||||
}
|
||||
|
||||
if (env.BRANCH_NAME == '__nightly-chestnut') {
|
||||
deviceStage("build nightly-chestnut", "mici-chestnut-ci", [], [
|
||||
step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"),
|
||||
])
|
||||
parallel (
|
||||
'nightly-chestnut': {
|
||||
deviceStage("build nightly-chestnut", "mici-chestnut-ci", ["CHESTNUT=1"], [
|
||||
step("build nightly-chestnut", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 RELEASE_BRANCH=nightly-chestnut $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"),
|
||||
])
|
||||
},
|
||||
'nightly-chestnut-dev': {
|
||||
deviceStage("build nightly-chestnut-dev", "mici-chestnut-ci", ["CHESTNUT=1"], [
|
||||
step("build nightly-chestnut-dev", "SCONSFLAGS=-j4 INCLUDE_BIG_MODEL=1 PANDA_DEBUG_BUILD=1 RELEASE_BRANCH=nightly-chestnut-dev $SOURCE_DIR/tools/release/build_release.sh TestChestnutOnroad"),
|
||||
])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!env.BRANCH_NAME.matches(excludeRegex)) {
|
||||
@@ -261,12 +308,7 @@ node {
|
||||
])
|
||||
},
|
||||
'chestnut': {
|
||||
deviceStage("chestnut", "mici-chestnut-ci", ["UNSAFE=1", "CHESTNUT=1"], [
|
||||
step("build", "./openpilot/selfdrive/test/chestnut.sh"),
|
||||
step("model replay", "openpilot/selfdrive/test/process_replay/model_replay.py --chestnut"),
|
||||
step("onroad tests", "./openpilot/selfdrive/test/test_onroad.py TestChestnutOnroad", [timeout: 120]),
|
||||
step("test power draw", "./openpilot/selfdrive/test/test_power_draw.py"),
|
||||
])
|
||||
chestnutStage()
|
||||
},
|
||||
|
||||
)
|
||||
|
||||
+11
-1
@@ -1,4 +1,14 @@
|
||||
Version 0.11.2 (2026-08-12)
|
||||
Version 0.11.3 (2026-XX-XX)
|
||||
========================
|
||||
* Autospeed
|
||||
* On supported cars, openpilot will intelligently decide what speed to drive based on the scene, including:
|
||||
* stopping at red lights, and going at green lights
|
||||
* slowing down for curves
|
||||
* adjusting speed based on surrounding traffic
|
||||
* This behavior was previously available with openpilot's "Experimental mode", but is now out of beta and always enabled
|
||||
* With openpilot deciding which speed to drive, the car's cruise speed is treated as a maximum speed openpilot will not exceed
|
||||
|
||||
Version 0.11.2 (2026-10-14)
|
||||
=======================
|
||||
* New driving model
|
||||
* Big model with 880M parameters
|
||||
|
||||
+1
-14
@@ -20,7 +20,7 @@ SetOption('num_jobs', max(1, int(os.cpu_count()/(1 if "CI" in os.environ else 2)
|
||||
|
||||
AddOption('--ccflags', action='store', type='string', default='', help='pass arbitrary flags over the command line')
|
||||
AddOption('--verbose', action='store_true', default=False, help='show full build commands')
|
||||
release = not os.path.exists(File('#.gitattributes').abspath) # file absent on release branch, see release_files.py
|
||||
release = not os.path.exists(File('#.gitmodules').abspath) # file absent on release branch, see release_files.py
|
||||
AddOption('--minimal',
|
||||
action='store_false',
|
||||
dest='extras',
|
||||
@@ -316,8 +316,6 @@ def count_scons_nodes(nodes):
|
||||
if node in seen:
|
||||
continue
|
||||
seen.add(node)
|
||||
if hasattr(node, 'has_builder') and node.has_builder():
|
||||
build_product_nodes.add(node)
|
||||
executor = node.get_executor()
|
||||
if executor is not None:
|
||||
stack += executor.get_all_prerequisites() + executor.get_all_children()
|
||||
@@ -326,7 +324,6 @@ def count_scons_nodes(nodes):
|
||||
|
||||
progress_interval = 5
|
||||
progress_count = 0
|
||||
build_product_nodes = set()
|
||||
progress_total = max(1, count_scons_nodes(env.arg2nodes(BUILD_TARGETS or [Dir('.')], env.fs.Entry)))
|
||||
|
||||
def progress_function(node):
|
||||
@@ -342,13 +339,3 @@ def progress_function(node):
|
||||
|
||||
Progress(progress_function, interval=progress_interval)
|
||||
AddPostAction(BUILD_TARGETS or [Dir('.')], prune_cache_dir)
|
||||
|
||||
def check_build_product_size(target, source, env):
|
||||
limit = 50 * 1024 * 1024 # GitHub max size
|
||||
for t in target:
|
||||
if str(t).endswith('.pkl'): # chunked during release packaging
|
||||
continue
|
||||
if hasattr(t, 'isfile') and t.isfile() and (size := os.path.getsize(t.abspath)) > limit:
|
||||
raise SCons.Errors.UserError(f"{t} is {size / (1024 * 1024):.1f} MiB, exceeding the {limit / (1024 * 1024):.1f} MiB limit")
|
||||
if not GetOption('extras'):
|
||||
AddPostAction(list(build_product_nodes), Action(check_build_product_size, None))
|
||||
|
||||
@@ -56,6 +56,8 @@ function launch {
|
||||
mv "$DIR" /data/safe_staging/old_openpilot
|
||||
mv "${STAGING_ROOT}/finalized" "$DIR"
|
||||
cd "$DIR"
|
||||
# releases before the openpilot/ move can't swap AGNOS on newer AGNOS, use ours
|
||||
[ ! -L system/hardware/tici/agnos.json ] && /data/safe_staging/old_openpilot/openpilot/common/hardware/comma/agnos.py --verify system/hardware/tici/agnos.json && sudo reboot
|
||||
|
||||
echo "Restarting launch script ${LAUNCHER_LOCATION}"
|
||||
unset AGNOS_VERSION
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export VECLIB_MAXIMUM_THREADS=1
|
||||
export QCOM_PRIORITY=12
|
||||
|
||||
if [ -z "$AGNOS_VERSION" ]; then
|
||||
export AGNOS_VERSION="19.7"
|
||||
export AGNOS_VERSION="19.9"
|
||||
fi
|
||||
|
||||
export STAGING_ROOT="/data/safe_staging"
|
||||
|
||||
+1
-1
Submodule opendbc_repo updated: b2acec1dc7...e183d91f99
@@ -196,7 +196,6 @@ struct ModelManagerSP @0xaedffd8f31e7b55d {
|
||||
}
|
||||
|
||||
struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
dec @0 :DynamicExperimentalControl;
|
||||
longitudinalPlanSource @1 :LongitudinalPlanSource;
|
||||
smartCruiseControl @2 :SmartCruiseControl;
|
||||
speedLimit @3 :SpeedLimit;
|
||||
@@ -205,7 +204,11 @@ struct LongitudinalPlanSP @0xf35cc4560bbf6ec2 {
|
||||
events @6 :List(OnroadEventSP.Event);
|
||||
e2eAlerts @7 :E2eAlerts;
|
||||
|
||||
struct DynamicExperimentalControl {
|
||||
deprecated :group {
|
||||
dec @0 :DynamicExperimentalControlDEPRECATED;
|
||||
}
|
||||
|
||||
struct DynamicExperimentalControlDEPRECATED {
|
||||
state @0 :DynamicExperimentalControlState;
|
||||
enabled @1 :Bool;
|
||||
active @2 :Bool;
|
||||
|
||||
+21
-16
@@ -128,6 +128,7 @@ struct OnroadEvent @0xc4fa6047f024e718 {
|
||||
personalityChanged @91;
|
||||
aeb @92;
|
||||
userBookmark @95;
|
||||
userBookmarkNotPaired @104;
|
||||
excessiveActuation @96;
|
||||
bigModelLoading @100;
|
||||
bigModelFailed @102;
|
||||
@@ -166,12 +167,7 @@ struct InitData {
|
||||
gitSrcCommit @23 :Text;
|
||||
gitSrcCommitDate @24 :Text;
|
||||
|
||||
androidProperties @16 :Map(Text, Text);
|
||||
|
||||
pandaInfo @8 :PandaInfo;
|
||||
|
||||
dirty @9 :Bool;
|
||||
passive @12 :Bool;
|
||||
params @17 :Map(Text, Data);
|
||||
|
||||
commands @19 :Map(Text, Data);
|
||||
@@ -189,6 +185,17 @@ struct InitData {
|
||||
mici @7; # comma four
|
||||
}
|
||||
|
||||
deprecated :group {
|
||||
gctx @1 :Text;
|
||||
androidBuildInfo @5 :Deprecated.AndroidBuildInfo;
|
||||
androidSensors @6 :List(Deprecated.AndroidSensor);
|
||||
chffrAndroidExtra @7 :ChffrAndroidExtra;
|
||||
pandaInfo @8 :PandaInfo;
|
||||
passive @12 :Bool;
|
||||
iosBuildInfo @14 :Deprecated.IosBuildInfo;
|
||||
androidProperties @16 :Map(Text, Text);
|
||||
}
|
||||
|
||||
struct PandaInfo {
|
||||
hasPanda @0 :Bool;
|
||||
dongleId @1 :Text;
|
||||
@@ -199,14 +206,6 @@ struct InitData {
|
||||
struct ChffrAndroidExtra {
|
||||
allCameraCharacteristics @0 :Map(Text, Text);
|
||||
}
|
||||
|
||||
deprecated :group {
|
||||
gctx @1 :Text;
|
||||
androidBuildInfo @5 :Deprecated.AndroidBuildInfo;
|
||||
androidSensors @6 :List(Deprecated.AndroidSensor);
|
||||
chffrAndroidExtra @7 :ChffrAndroidExtra;
|
||||
iosBuildInfo @14 :Deprecated.IosBuildInfo;
|
||||
}
|
||||
}
|
||||
|
||||
struct FrameData {
|
||||
@@ -572,6 +571,9 @@ struct PandaState @0xa7649e2575e4591e {
|
||||
controlsAllowedLateral @38 :Bool;
|
||||
controlsAllowedLongitudinal @39 :Bool;
|
||||
|
||||
nmiReset @40 :Bool;
|
||||
hardfaultReset @41 :Bool;
|
||||
|
||||
enum FaultStatus {
|
||||
none @0;
|
||||
faultTemp @1;
|
||||
@@ -694,6 +696,7 @@ struct PeripheralState {
|
||||
|
||||
struct UsbState {
|
||||
devices @0 :List(Device);
|
||||
connected @1 :Bool;
|
||||
|
||||
struct Device {
|
||||
busnum @0 :UInt8;
|
||||
@@ -819,7 +822,6 @@ struct SelfdriveState {
|
||||
alertHudVisual @12 :Car.CarControl.HUDControl.VisualAlert;
|
||||
|
||||
# configurable driving settings
|
||||
experimentalMode @10 :Bool;
|
||||
personality @11 :LongitudinalPersonality;
|
||||
|
||||
enum AudibleAlert {
|
||||
@@ -863,6 +865,7 @@ struct SelfdriveState {
|
||||
|
||||
deprecated :group {
|
||||
alertSound @8 :Car.CarControl.HUDControl.AudibleAlert;
|
||||
experimentalMode @10 :Bool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,6 +1240,7 @@ struct LongitudinalPlan @0xe00b5b3eba12876c {
|
||||
shouldStop @37: Bool;
|
||||
allowThrottle @38: Bool;
|
||||
allowBrake @39: Bool;
|
||||
accelBoost @40 :Float32;
|
||||
|
||||
|
||||
solverExecutionTime @35 :Float32;
|
||||
@@ -2234,6 +2238,7 @@ struct DriverMonitoringState {
|
||||
pose @0: Bool;
|
||||
eye @1: Bool;
|
||||
phone @2: Bool;
|
||||
sleep @3: Bool;
|
||||
}
|
||||
|
||||
struct Pose {
|
||||
@@ -2705,8 +2710,8 @@ struct Event {
|
||||
driverMonitoringStateDEPRECATED @71 :DriverMonitoringStateDEPRECATED;
|
||||
gpsNMEADEPRECATED @3 :GPSNMEAData;
|
||||
uploaderStateDEPRECATED @79 :UploaderState;
|
||||
navInstructionDEPRECATED @82 :NavInstruction;
|
||||
navRouteDEPRECATED @83 :NavRoute;
|
||||
navInstruction @82 :NavInstruction;
|
||||
navRoute @83 :NavRoute;
|
||||
navThumbnailDEPRECATED @84 :Thumbnail;
|
||||
gnssMeasurementsDEPRECATED @91 :GnssMeasurements;
|
||||
mapRenderStateDEPRECATED @105: MapRenderState;
|
||||
|
||||
@@ -52,6 +52,8 @@ _services: dict[str, tuple] = {
|
||||
"procLog": (True, 0.5, 15, QueueSize.BIG),
|
||||
"gpsLocationExternal": (True, 10., 10),
|
||||
"gpsLocation": (True, 1., 1),
|
||||
"navInstruction": (True, 1., 10),
|
||||
"navRoute": (True, 0.),
|
||||
"ubloxGnss": (True, 10.),
|
||||
"qcomGnss": (True, 2.),
|
||||
"clocks": (True, 0.1, 1),
|
||||
|
||||
@@ -16,6 +16,10 @@ class CV:
|
||||
DEG_TO_RAD = np.pi / 180.
|
||||
RAD_TO_DEG = 1. / DEG_TO_RAD
|
||||
|
||||
# Length
|
||||
METER_TO_FOOT = 3.28084
|
||||
METER_TO_MILE = 1. / 1609.344
|
||||
|
||||
# Mass
|
||||
LB_TO_KG = 0.453592
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
WEBBING_ICCID_PREFIX = "8985235"
|
||||
|
||||
|
||||
class LPAError(RuntimeError):
|
||||
pass
|
||||
@@ -19,7 +21,7 @@ class Profile:
|
||||
|
||||
@property
|
||||
def is_comma(self) -> bool:
|
||||
return self.provider == 'Webbing' and self.iccid.startswith('8985235')
|
||||
return self.provider == 'Webbing' and self.iccid.startswith(WEBBING_ICCID_PREFIX)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
|
||||
@@ -27,16 +27,6 @@ def gpio_read(pin: int) -> bool | None:
|
||||
|
||||
return val
|
||||
|
||||
def gpio_export(pin: int) -> None:
|
||||
if os.path.isdir(f"/sys/class/gpio/gpio{pin}"):
|
||||
return
|
||||
|
||||
try:
|
||||
with open("/sys/class/gpio/export", 'w') as f:
|
||||
f.write(str(pin))
|
||||
except Exception:
|
||||
print(f"Failed to export gpio {pin}")
|
||||
|
||||
@cache
|
||||
def get_irq_action(irq: int) -> list[str]:
|
||||
try:
|
||||
|
||||
@@ -56,28 +56,28 @@
|
||||
},
|
||||
{
|
||||
"name": "boot",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d.img.xz",
|
||||
"hash": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
|
||||
"hash_raw": "6ecf6f987cd11968104abcccabbe268485d329cdb73012dfd3c381a6b8deb27d",
|
||||
"size": 46897152,
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615.img.xz",
|
||||
"hash": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615",
|
||||
"hash_raw": "b9c9b9261fca0cef9e6d9b198fa4b563767e6342afc7a61ab137b2b519260615",
|
||||
"size": 46962688,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "d12e1e5b9455b62a1464558716493b33e470d7a7e88da1c4105a3b21d0961808"
|
||||
"ondevice_hash": "3ef4769bb1cc73bfc31f96f073b46af42dcc0bad4ab2dde32d11929745d0cd28"
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img.xz",
|
||||
"hash": "74ffc9c551e1f29cda897ace8a69080fe644f8039977c6885f2b48362e39b744",
|
||||
"hash_raw": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img.xz",
|
||||
"hash": "9aebca09b9d142718e71efbfe44b723010eec6e70067c043c63186067291739d",
|
||||
"hash_raw": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7",
|
||||
"size": 4718592000,
|
||||
"sparse": true,
|
||||
"full_check": false,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "6a992680183685eea9db99d915219a37935f45989330d9b619e880450257f448",
|
||||
"ondevice_hash": "270b76c5c5568828e3883390b83ea445425a104dd42119d68f06093ce6b6fcef",
|
||||
"alt": {
|
||||
"hash": "3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-3c271e2b3d20d2f0a8bf6555a1319f3efb12845490967d6151195174a01e912f.img",
|
||||
"hash": "80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-80d9373348ddff2a5d1f534fab3420383939c96f3eba60baab6b15339fc602c7.img",
|
||||
"size": 4718592000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from functools import cached_property, lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.esim.base import WEBBING_ICCID_PREFIX
|
||||
from openpilot.common.utils import sudo_read, sudo_write
|
||||
from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action
|
||||
from openpilot.common.esim.base import LPABase
|
||||
@@ -207,6 +208,8 @@ class HardwareComma(HardwareBase):
|
||||
|
||||
def get_network_metered(self, network_type) -> bool:
|
||||
if network_type in (NetworkType.cell2G, NetworkType.cell3G, NetworkType.cell4G, NetworkType.cell5G):
|
||||
if self.get_modem_state().get("iccid", "").startswith(WEBBING_ICCID_PREFIX):
|
||||
return True
|
||||
from openpilot.common.params import Params
|
||||
return Params().get_bool("GsmMetered")
|
||||
try:
|
||||
|
||||
@@ -17,6 +17,8 @@ from ipaddress import IPv4Address, AddressValueError
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from openpilot.common.esim.base import WEBBING_ICCID_PREFIX
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s.%(msecs)03d %(levelname)-7s modem: %(message)s",
|
||||
@@ -44,7 +46,6 @@ NETWORK_TYPE = {0: "gsm", 1: "gsm", 3: "gsm", 8: "gsm",
|
||||
11: "nr", 12: "nr", 13: "nr"}
|
||||
|
||||
DIAL_CID = 1
|
||||
WEBBING_ICCID_PREFIX = "8985235"
|
||||
|
||||
PPPD_CMD = [
|
||||
"sudo", "pppd", PPP_PORT, "460800", "noauth", "nodetach", "noipdefault", "usepeerdns",
|
||||
|
||||
@@ -9,25 +9,22 @@ from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
|
||||
|
||||
def read_power(panda=None):
|
||||
if panda is not None and panda.get_type() == panda.HW_TYPE_CUATRO:
|
||||
health = panda.health()
|
||||
return health['voltage'] * health['current'] / 1e6
|
||||
def read_power():
|
||||
with open("/sys/bus/i2c/devices/0-0040/hwmon/hwmon1/power1_input") as f:
|
||||
return int(f.read()) / 1e6
|
||||
|
||||
def sample_power(seconds=5, panda=None) -> list[float]:
|
||||
def sample_power(seconds=5) -> list[float]:
|
||||
rate = 123
|
||||
rk = Ratekeeper(rate, print_delay_threshold=None)
|
||||
|
||||
pwrs = []
|
||||
for _ in range(rate*seconds):
|
||||
pwrs.append(read_power(panda))
|
||||
pwrs.append(read_power())
|
||||
rk.keep_time()
|
||||
return pwrs
|
||||
|
||||
def get_power(seconds=5, panda=None):
|
||||
pwrs = sample_power(seconds, panda)
|
||||
def get_power(seconds=5):
|
||||
pwrs = sample_power(seconds)
|
||||
return np.mean(pwrs)
|
||||
|
||||
def wait_for_power(min_pwr, max_pwr, min_secs_in_range, timeout):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a7adb302d378dda7b1788a841b89e4f905c872550a70a76650dcde977b4ece0
|
||||
size 24709209
|
||||
oid sha256:f92c0a9b41613e063b6a1cd086729f5555d9df6482249e171ed1e276272cad7d
|
||||
size 24709520
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
CHESTNUT_FW_VERSION = "ed4e39b7"
|
||||
CHESTNUT_FW_VERSION = "8516b768"
|
||||
CHESTNUT_USB_IDS = ((0xADD1, 0x0001), (0x3801, 0x0001))
|
||||
CHESTNUT_ROM_USB_IDS = ((0x174C, 0x2464), (0x174C, 0x2463))
|
||||
CHESTNUT_USB_PRODUCT = f"custom {CHESTNUT_FW_VERSION}-CLEAN"
|
||||
@@ -36,6 +36,10 @@ def read_int(path: Path, base: int = 10) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cable_connected() -> bool:
|
||||
return read_int(TYPEC_CC_ORIENTATION_PATH) != 0
|
||||
|
||||
|
||||
def usb_devices() -> list[Path]:
|
||||
try:
|
||||
devices = (d for d in USB_DEVICES_PATH.glob("*") if (d / "idVendor").exists())
|
||||
@@ -91,3 +95,4 @@ def set_usb_state(device_state, devices: list[dict]) -> None:
|
||||
chestnut_present = True
|
||||
|
||||
device_state.chestnutPresent = chestnut_present
|
||||
device_state.usbState.connected = cable_connected()
|
||||
|
||||
@@ -139,6 +139,15 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"UptimeOnroad", {PERSISTENT, FLOAT, "0.0"}},
|
||||
{"ChestnutActive", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"ChestnutLoading", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"ChestnutRetry", {CLEAR_ON_MANAGER_START | CLEAR_ON_IGNITION_ON, BOOL}},
|
||||
{"InitialCruiseSpeedKph", {PERSISTENT, INT, "40"}},
|
||||
{"NavDestination", {CLEAR_ON_MANAGER_START | CLEAR_ON_OFFROAD_TRANSITION, JSON}},
|
||||
{"NavigationEnabled", {PERSISTENT, BOOL, "0"}},
|
||||
{"Offroad_ChestnutModelError", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_Pairing", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"Offroad_UploadsIgnored", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"PairingEmail", {PERSISTENT, STRING}},
|
||||
{"PairingProvider", {PERSISTENT, STRING}},
|
||||
{"Version", {PERSISTENT, STRING}},
|
||||
|
||||
// --- sunnypilot params --- //
|
||||
@@ -240,7 +249,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"ToyotaEnforceStockLongitudinal", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"ToyotaStopAndGoHack", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
{"DynamicExperimentalControl", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"BlindSpot", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
|
||||
// sunnypilot model params
|
||||
|
||||
@@ -91,8 +91,13 @@ class Ratekeeper:
|
||||
self.avg_dt.add_value(self._last_monitor_time - prev)
|
||||
|
||||
lagged = False
|
||||
remaining = self._next_frame_time - time.monotonic()
|
||||
self._next_frame_time += self._interval
|
||||
now = time.monotonic()
|
||||
remaining = self._next_frame_time - now
|
||||
if remaining < -self._interval:
|
||||
# more than a frame behind (e.g. a stall): resync instead of running catch-up frames back-to-back
|
||||
self._next_frame_time = now + self._interval
|
||||
else:
|
||||
self._next_frame_time += self._interval
|
||||
if self._print_delay_threshold is not None and remaining < -self._print_delay_threshold:
|
||||
print(f"{self._process_name} lagging by {-remaining * 1000:.2f} ms")
|
||||
lagged = True
|
||||
|
||||
@@ -5,7 +5,6 @@ import contextlib
|
||||
import subprocess
|
||||
import time
|
||||
import functools
|
||||
from subprocess import Popen, PIPE, TimeoutExpired
|
||||
import zstandard as zstd
|
||||
|
||||
LOG_COMPRESSION_LEVEL = 10 # little benefit up to level 15. level ~17 is a small step change
|
||||
@@ -157,20 +156,6 @@ def run_cmd_default(cmd: list[str], default: str = "", cwd=None, env=None) -> st
|
||||
return default
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def managed_proc(cmd: list[str], env: dict[str, str]):
|
||||
proc = Popen(cmd, env=env, stdout=PIPE, stderr=PIPE)
|
||||
try:
|
||||
yield proc
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def tabulate(tabular_data, headers=(), tablefmt="simple", floatfmt="g", stralign="left", numalign=None):
|
||||
rows = [list(row) for row in tabular_data]
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
#define COMMA_VERSION "0.11.2"
|
||||
#define COMMA_VERSION "0.11.3"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:911f59f248015600da7ecc689398103d47dfc57f6be17ac8c8e543a726a6c64b
|
||||
size 3311
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dd5d8d3fce5e30662797f7eeed224f5775b2cabcc055f01f5ebf6e7b2657b616
|
||||
size 1801
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e58deb1778cf2826339f27e9f09eecc79ea137c1436210c14b71c352a649c77
|
||||
size 34953
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7ab989c9fe22e7d2119bac5284cdccfba2889477d69f5f6c6b4470c7ee0aab3
|
||||
size 1801
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8f37a02dd914405c6f86f415700dd5985eb976b923e7abd6580d2da76533594e
|
||||
size 9466
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90d441310f8e1833c661d8a4f0546aab2eb50eb21cc21beb0aff0d27b5ee6066
|
||||
size 1571
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6b2dad33fead9f064c3a548651d6ef37daf82b6127c329683f538ec6e986ecbc
|
||||
size 11204
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:82648254da89edb0cf65ef63fc5e6e0741414051bfea8a40449132fd61b1ed0f
|
||||
size 1533
|
||||
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01841b602632c66ab14a8e52b874a1623f09641dc2ef0620f4e2d00bb4a913f3
|
||||
size 16243
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e0936a3d890a3ad68ffe021e4905b9b9e59ffc2ae19ee9c703daa3e7c92e7c78
|
||||
size 1793
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:57c7ba72642b0350ee764c924586fa66ff94915be4aba1813797e67eb4b74376
|
||||
size 33215
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:71dc069bd3dc5b11ba8e1b52eb3a1025ee0ad8c82834f94b4b2269ec0d401920
|
||||
size 237
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:73a6fe02cf19105c0051a6664a424c1bf2ede364c6cf3791f235c86ffa203f39
|
||||
size 10348
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4f500f118dca3eedbd86fc6cc943ad8be67fe5d4f7b474d5b452cd757ad805c3
|
||||
size 282
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a49996f0ce069a048abe94ba9424de655dcff53745676ab35e045204af88a3c
|
||||
size 12103
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8542374779f1908dc52c8a126bc041efea4c4d605afdd34e3bf8e520a7e78bf2
|
||||
size 320
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:21e656b387c1a7e8de54d3e1b1c246f442cffe3c5e0924273d6d6d897c00b568
|
||||
size 13868
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fd91685bf656e828648acf035a4737acb2c4709e8514cf0aa0a10fa470a9bb60
|
||||
size 11580
|
||||
oid sha256:92431d37a844206570d73d48f39dd8d934539a89668fe8d5f4cccae38f7b301b
|
||||
size 11530
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3efe7d12ae82e0f5a1bd773129cf1eafe3c8c36ff5cba43a6c95f295706062b5
|
||||
size 19724
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dfb645e59636687d61d20283bae65b68ddda6f7b2d4fda01a6e613a842e3b41f
|
||||
size 823
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:df07383a6ddfe8cd7eeb2f22271cbd13068327ca0978873f8d10368860562e40
|
||||
size 111083
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7518f3409e2ff8f7e99fb7f0c06afa8bdf0b0a506147b5cf9d4931b820a1a75
|
||||
size 1107
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a18e3de5f455b4089a824da8d5d78d5d532ef71facb05cc75c178b24d5beeb7
|
||||
size 920
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:710e74b526adb5bbac15cd8915a828dd673dd8ce15b2fd694c64c182eef6f56f
|
||||
size 13744
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:25db08a9038b5faa3b200ef5625cb216d7f4b31ea3d49fb0395f9b726bfaf8e6
|
||||
size 13135
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:547f466cc59d29647f3bf131d8444d7f22b63b186bcea55ceb8109c68ca74fe8
|
||||
size 2345
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:55050096cd98e7f4ea3e843d927c18815e458dfbd5e7d100b503db92075d061b
|
||||
size 2373
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f9c439ed89bc0357e86c005d5dbb6f1ad2b8d72cdc0890e3201d842d8c3928f1
|
||||
size 2443
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7aeccd3e0d8517f74eb34f7d73632c0b29b0ad5df5b9686c0f509bb6b136561e
|
||||
size 1728
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d02369e21d3c78a3a5ece7e78deedb82343bc3228a28db73da48b5c27860004
|
||||
size 52374
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3bef898e4e784baf98f3ceca53cbe95b1020da5e01162cdaa14b79b1ce0b1340
|
||||
size 52374
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1bb440f424c989c06e203bfba0b32d39aede8f6c78a9b335e5e778460202b601
|
||||
oid sha256:884e828e5c51d980d41b6e2400fcb4f4a926275018a00674d8239184e1eb96db
|
||||
size 73026
|
||||
|
||||
@@ -127,9 +127,6 @@ class Car:
|
||||
set_alternative_experience(self.CP, self.CP_SP, self.params)
|
||||
set_car_specific_params(self.CP, self.CP_SP, self.params)
|
||||
|
||||
# Dynamic Experimental Control
|
||||
self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl")
|
||||
|
||||
openpilot_enabled_toggle = self.params.get_bool("OpenpilotEnabledToggle")
|
||||
controller_available = self.CI.CC is not None and openpilot_enabled_toggle and not self.CP.dashcamOnly
|
||||
self.CP.passive = not controller_available or self.CP.dashcamOnly
|
||||
@@ -181,7 +178,7 @@ class Car:
|
||||
self.v_cruise_helper = VCruiseHelper(self.CP, self.CP_SP)
|
||||
|
||||
self.is_metric = self.params.get_bool("IsMetric")
|
||||
self.experimental_mode = self.params.get_bool("ExperimentalMode")
|
||||
self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True)
|
||||
|
||||
# card is driven by can recv, expected at 100Hz
|
||||
self.rk = Ratekeeper(100, print_delay_threshold=None)
|
||||
@@ -214,7 +211,7 @@ class Car:
|
||||
self.v_cruise_helper.update_v_cruise(CS, self.sm['carControl'].enabled, self.is_metric)
|
||||
if self.sm['carControl'].enabled and not self.CC_prev.enabled:
|
||||
# Use CarState w/ buttons from the step selfdrived enables on
|
||||
self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.experimental_mode, self.dynamic_experimental_control)
|
||||
self.v_cruise_helper.initialize_v_cruise(self.CS_prev, self.initial_cruise_speed)
|
||||
|
||||
# TODO: mirror the carState.cruiseState struct?
|
||||
CS.vCruise = float(self.v_cruise_helper.v_cruise_kph)
|
||||
@@ -300,9 +297,9 @@ class Car:
|
||||
while not evt.is_set():
|
||||
self.is_metric = self.params.get_bool("IsMetric")
|
||||
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
|
||||
self.initial_cruise_speed = self.params.get("InitialCruiseSpeedKph", return_default=True)
|
||||
|
||||
# sunnypilot
|
||||
self.dynamic_experimental_control = self.params.get_bool("DynamicExperimentalControl")
|
||||
self.v_cruise_helper.read_custom_set_speed_params()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -13,7 +13,6 @@ V_CRUISE_MIN = 8
|
||||
V_CRUISE_MAX = 145
|
||||
V_CRUISE_UNSET = 255
|
||||
V_CRUISE_INITIAL = 40
|
||||
V_CRUISE_INITIAL_EXPERIMENTAL_MODE = 105
|
||||
IMPERIAL_INCREMENT = round(CV.MPH_TO_KPH, 1) # round here to avoid rounding errors incrementing set speed
|
||||
|
||||
ButtonEvent = car.CarState.ButtonEvent
|
||||
@@ -138,17 +137,14 @@ class VCruiseHelper(VCruiseHelperSP):
|
||||
self.button_timers[b.type.raw] = 1 if b.pressed else 0
|
||||
self.button_change_states[b.type.raw] = {"standstill": CS.cruiseState.standstill, "enabled": enabled}
|
||||
|
||||
def initialize_v_cruise(self, CS, experimental_mode: bool, dynamic_experimental_control: bool) -> None:
|
||||
def initialize_v_cruise(self, CS, initial_speed: int) -> None:
|
||||
# initializing is handled by the PCM
|
||||
if self.CP.pcmCruise:
|
||||
return
|
||||
|
||||
initial_experimental_mode = experimental_mode and not dynamic_experimental_control
|
||||
initial = V_CRUISE_INITIAL_EXPERIMENTAL_MODE if initial_experimental_mode else V_CRUISE_INITIAL
|
||||
|
||||
if any(b.type in (ButtonType.accelCruise, ButtonType.resumeCruise) for b in CS.buttonEvents) and self.v_cruise_initialized:
|
||||
self.v_cruise_kph = self.v_cruise_kph_last
|
||||
else:
|
||||
self.v_cruise_kph = int(round(np.clip(CS.vEgo * CV.MS_TO_KPH, initial, V_CRUISE_MAX)))
|
||||
self.v_cruise_kph = int(round(np.clip(max(CS.vEgo * CV.MS_TO_KPH, initial_speed), V_CRUISE_INITIAL, V_CRUISE_MAX)))
|
||||
|
||||
self.v_cruise_cluster_kph = self.v_cruise_kph
|
||||
|
||||
@@ -14,7 +14,7 @@ ButtonEvent = car.CarState.ButtonEvent
|
||||
ButtonType = car.CarState.ButtonEvent.Type
|
||||
|
||||
|
||||
def run_cruise_simulation(cruise, e2e, personality, t_end=20.):
|
||||
def run_cruise_simulation(cruise, personality, t_end=20.):
|
||||
man = Maneuver(
|
||||
'',
|
||||
duration=t_end,
|
||||
@@ -24,7 +24,6 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.):
|
||||
cruise_values=[cruise],
|
||||
prob_lead_values=[0.0],
|
||||
breakpoints=[0.],
|
||||
e2e=e2e,
|
||||
personality=personality,
|
||||
)
|
||||
valid, output = man.evaluate()
|
||||
@@ -32,8 +31,7 @@ def run_cruise_simulation(cruise, e2e, personality, t_end=20.):
|
||||
return output[-1, 3]
|
||||
|
||||
|
||||
@parameterized_class(("e2e", "personality", "speed"), itertools.product(
|
||||
[True, False], # e2e
|
||||
@parameterized_class(("personality", "speed"), itertools.product(
|
||||
log.LongitudinalPersonality.schema.enumerants, # personality
|
||||
[5,35])) # speed
|
||||
class TestCruiseSpeed(OpenpilotTestCase):
|
||||
@@ -41,7 +39,7 @@ class TestCruiseSpeed(OpenpilotTestCase):
|
||||
print(f'Testing {self.speed} m/s')
|
||||
cruise_speed = float(self.speed)
|
||||
|
||||
simulation_steady_state = run_cruise_simulation(cruise_speed, self.e2e, self.personality)
|
||||
simulation_steady_state = run_cruise_simulation(cruise_speed, self.personality)
|
||||
self.assertAlmostEqual(simulation_steady_state, cruise_speed, delta=.01, msg=f'Did not reach {self.speed} m/s')
|
||||
|
||||
|
||||
@@ -59,16 +57,16 @@ class TestVCruiseHelper(OpenpilotTestCase):
|
||||
for _ in range(2):
|
||||
self.v_cruise_helper.update_v_cruise(car.CarState(cruiseState={"available": False}), enabled=False, is_metric=False)
|
||||
|
||||
def enable(self, v_ego, experimental_mode, dynamic_experimental_control):
|
||||
def enable(self, v_ego):
|
||||
# Simulates user pressing set with a current speed
|
||||
self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), experimental_mode, dynamic_experimental_control)
|
||||
self.v_cruise_helper.initialize_v_cruise(car.CarState(vEgo=v_ego), V_CRUISE_INITIAL)
|
||||
|
||||
def test_adjust_speed(self):
|
||||
"""
|
||||
Asserts speed changes on falling edges of buttons.
|
||||
"""
|
||||
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False)
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS)
|
||||
|
||||
for btn in (ButtonType.accelCruise, ButtonType.decelCruise):
|
||||
for pressed in (True, False):
|
||||
@@ -92,7 +90,7 @@ class TestVCruiseHelper(OpenpilotTestCase):
|
||||
CS.buttonEvents = [ButtonEvent(type=ButtonType.decelCruise, pressed=pressed)]
|
||||
self.v_cruise_helper.update_v_cruise(CS, enabled=enabled, is_metric=False)
|
||||
if pressed:
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False)
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS)
|
||||
|
||||
# Expected diff on enabling. Speed should not change on falling edge of pressed
|
||||
assert (not pressed) == (self.v_cruise_helper.v_cruise_kph == self.v_cruise_helper.v_cruise_kph_last)
|
||||
@@ -102,7 +100,7 @@ class TestVCruiseHelper(OpenpilotTestCase):
|
||||
Asserts we don't increment set speed if user presses resume/accel to exit cruise standstill.
|
||||
"""
|
||||
|
||||
self.enable(0, False, False)
|
||||
self.enable(0)
|
||||
|
||||
for standstill in (True, False):
|
||||
for pressed in (True, False):
|
||||
@@ -122,7 +120,7 @@ class TestVCruiseHelper(OpenpilotTestCase):
|
||||
|
||||
for v_ego in np.linspace(0, 100, 101):
|
||||
self.reset_cruise_speed_state()
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS, False, False)
|
||||
self.enable(V_CRUISE_INITIAL * CV.KPH_TO_MS)
|
||||
|
||||
# first decrement speed, then perform gas pressed logic
|
||||
expected_v_cruise_kph = self.v_cruise_helper.v_cruise_kph - IMPERIAL_INCREMENT
|
||||
@@ -143,12 +141,10 @@ class TestVCruiseHelper(OpenpilotTestCase):
|
||||
Asserts allowed cruise speeds on enabling with SET.
|
||||
"""
|
||||
|
||||
for experimental_mode in (True, False):
|
||||
for dynamic_experimental_control in (True, False):
|
||||
for v_ego in np.linspace(0, 100, 101):
|
||||
self.reset_cruise_speed_state()
|
||||
assert not self.v_cruise_helper.v_cruise_initialized
|
||||
for v_ego in np.linspace(0, 100, 101):
|
||||
self.reset_cruise_speed_state()
|
||||
assert not self.v_cruise_helper.v_cruise_initialized
|
||||
|
||||
self.enable(float(v_ego), experimental_mode, dynamic_experimental_control)
|
||||
assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX
|
||||
assert self.v_cruise_helper.v_cruise_initialized
|
||||
self.enable(float(v_ego))
|
||||
assert V_CRUISE_INITIAL <= self.v_cruise_helper.v_cruise_kph <= V_CRUISE_MAX
|
||||
assert self.v_cruise_helper.v_cruise_initialized
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import numpy as np
|
||||
from openpilot.common.constants import CV
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
|
||||
ACCEL_BOOST_MAX = 0.2
|
||||
ACCEL_BOOST_RATE = 0.025
|
||||
ACCEL_BOOST_PER_OVERRIDE = 0.05
|
||||
ACCEL_BOOST_MIN_SPEED = 10 * CV.MPH_TO_MS
|
||||
|
||||
|
||||
class AccelBoost:
|
||||
def __init__(self):
|
||||
self.total_boost = 0.0
|
||||
self.boost_this_override = 0.0
|
||||
self.boost_eligible = False
|
||||
|
||||
def update(self, sm, output_a_target_e2e, output_a_target_mpc, a_cruise):
|
||||
enabled = sm['selfdriveState'].enabled
|
||||
gas_pressed = sm['carState'].gasPressed
|
||||
v_ego = sm['carState'].vEgo
|
||||
speed_scale = np.interp(v_ego, [ACCEL_BOOST_MIN_SPEED, 2 * ACCEL_BOOST_MIN_SPEED], [0.0, 1.0])
|
||||
boost_scale = speed_scale * np.interp(output_a_target_e2e, [-1.0, -0.5, 5.0], [0.0, 1.0, 1.0], right=0.0)
|
||||
model_limited = output_a_target_e2e + self.total_boost * boost_scale < min(output_a_target_mpc, a_cruise) - 0.1
|
||||
|
||||
if not enabled or not gas_pressed:
|
||||
self.boost_this_override = 0.0
|
||||
# Latch eligibility before the override changes the lead plan.
|
||||
self.boost_eligible = enabled and model_limited
|
||||
|
||||
if not enabled:
|
||||
self.total_boost = 0.0
|
||||
elif gas_pressed and self.boost_eligible:
|
||||
increase = min(ACCEL_BOOST_RATE * DT_MDL * speed_scale,
|
||||
ACCEL_BOOST_PER_OVERRIDE - self.boost_this_override, ACCEL_BOOST_MAX - self.total_boost)
|
||||
self.total_boost += increase
|
||||
self.boost_this_override += increase
|
||||
|
||||
return output_a_target_e2e + self.total_boost * boost_scale
|
||||
@@ -4,7 +4,6 @@ from openpilot.common.realtime import DT_CTRL, DT_MDL
|
||||
|
||||
MIN_SPEED = 1.0
|
||||
CONTROL_N = 17
|
||||
CAR_ROTATION_RADIUS = 0.0
|
||||
# This is a turn radius smaller than most cars can achieve
|
||||
MAX_CURVATURE = 0.2
|
||||
MIN_STABLE_DELAY = 0.3
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import math
|
||||
import numpy as np
|
||||
from collections import deque
|
||||
|
||||
from openpilot.cereal import log
|
||||
from openpilot.common.pid import PIDController
|
||||
from openpilot.selfdrive.controls.lib.latcontrol import LatControl
|
||||
from openpilot.selfdrive.controls.lib.drive_helpers import MAX_CURVATURE
|
||||
|
||||
CURVATURE_SATURATION_THRESHOLD = 1e-3 # 1/m
|
||||
LAT_ACCEL_SATURATION_THRESHOLD = 0.5 # m/s^2
|
||||
|
||||
|
||||
class LatControlCurvature(LatControl):
|
||||
@@ -20,6 +22,8 @@ class LatControlCurvature(LatControl):
|
||||
else:
|
||||
self.pid = None
|
||||
self.kf = 1.
|
||||
self.desired_curvature_buffer_len = int(1. / self.dt)
|
||||
self.desired_curvature_buffer = deque([0.] * self.desired_curvature_buffer_len, maxlen=self.desired_curvature_buffer_len)
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
@@ -30,6 +34,9 @@ class LatControlCurvature(LatControl):
|
||||
curvature_log = log.ControlsState.LateralCurvatureState.new_message()
|
||||
actual_curvature = -VM.calc_curvature(math.radians(CS.steeringAngleDeg - params.angleOffsetDeg), CS.vEgo, params.roll)
|
||||
error = desired_curvature - actual_curvature
|
||||
self.desired_curvature_buffer.append(desired_curvature)
|
||||
delay_frames = int(np.clip(lat_delay / self.dt + 1, 1, self.desired_curvature_buffer_len))
|
||||
expected_curvature = self.desired_curvature_buffer[-delay_frames]
|
||||
|
||||
if not active:
|
||||
output_curvature = 0.0
|
||||
@@ -53,6 +60,6 @@ class LatControlCurvature(LatControl):
|
||||
curvature_log.actualCurvature = float(actual_curvature)
|
||||
curvature_log.desiredCurvature = float(desired_curvature)
|
||||
curvature_log.output = float(output_curvature)
|
||||
curvature_log.saturated = bool(self._check_saturation(abs(error) > CURVATURE_SATURATION_THRESHOLD, CS,
|
||||
curvature_log.saturated = bool(self._check_saturation(abs(expected_curvature - actual_curvature) * CS.vEgo ** 2 > LAT_ACCEL_SATURATION_THRESHOLD, CS,
|
||||
False, curvature_limited))
|
||||
return 0.0, float(output_curvature), curvature_log
|
||||
|
||||
@@ -8,6 +8,7 @@ from openpilot.common.constants import CV
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import DT_MDL
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.selfdrive.controls.lib.accel_boost import AccelBoost
|
||||
from openpilot.selfdrive.controls.lib.longcontrol import LongCtrlState
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import LongitudinalMpc, LongitudinalPlanSource
|
||||
from openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.long_mpc import T_IDXS as T_IDXS_MPC
|
||||
@@ -25,7 +26,6 @@ CONTROL_N_T_IDX = ModelConstants.T_IDXS[:CONTROL_N]
|
||||
ALLOW_THROTTLE_THRESHOLD = 0.4
|
||||
MIN_ALLOW_THROTTLE_SPEED = 2.5
|
||||
|
||||
# Lookup table for turns
|
||||
_A_TOTAL_MAX_V = [1.7, 3.2]
|
||||
_A_TOTAL_MAX_BP = [20., 40.]
|
||||
|
||||
@@ -33,7 +33,7 @@ def get_max_accel(v_ego):
|
||||
return np.interp(v_ego, A_CRUISE_MAX_BP, A_CRUISE_MAX_VALS)
|
||||
|
||||
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
|
||||
return np.sin(pitch) * -5.65 - 0.3
|
||||
|
||||
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)
|
||||
@@ -62,6 +62,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
LongitudinalPlannerSP.__init__(self, self.CP, CP_SP, self.mpc)
|
||||
self.fcw = False
|
||||
self.dt = dt
|
||||
self.accel_boost = AccelBoost()
|
||||
self.allow_throttle = True
|
||||
|
||||
self.v_desired_filter = FirstOrderFilter(init_v, 2.0, self.dt)
|
||||
@@ -89,18 +90,18 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
|
||||
long_control_off = sm['controlsState'].longControlState == LongCtrlState.off
|
||||
|
||||
# Reset current state when not engaged, or user is controlling the speed
|
||||
reset_state = long_control_off if self.CP.openpilotLongitudinalControl else not sm['selfdriveState'].enabled
|
||||
# PCM cruise speed may be updated a few cycles later, check if initialized
|
||||
v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET
|
||||
reset_state = reset_state or not v_cruise_initialized
|
||||
|
||||
throttle_probs = sm['modelV2'].meta.disengagePredictions.gasPressProbs
|
||||
throttle_prob = throttle_probs[1] if len(throttle_probs) > 1 else 1.0
|
||||
self.allow_throttle = throttle_prob > ALLOW_THROTTLE_THRESHOLD or v_ego <= MIN_ALLOW_THROTTLE_SPEED
|
||||
|
||||
steer_angle_without_offset = sm['carState'].steeringAngleDeg - sm['vehicleParameters'].angleOffsetDeg
|
||||
|
||||
# Reset current state when not engaged, or user is controlling the speed
|
||||
reset_state = long_control_off if self.CP.openpilotLongitudinalControl else not sm['selfdriveState'].enabled
|
||||
# PCM cruise speed may be updated a few cycles later, check if initialized
|
||||
v_cruise_initialized = sm['carState'].vCruise != V_CRUISE_UNSET
|
||||
reset_state = reset_state or not v_cruise_initialized
|
||||
|
||||
if reset_state:
|
||||
self.v_desired_filter.x = v_ego
|
||||
self.output_a_target = np.clip(sm['carState'].aEgo, ACCEL_MIN, ACCEL_MAX)
|
||||
@@ -116,7 +117,10 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
v_cruise, self.output_a_target = LongitudinalPlannerSP.update_targets(self, sm, self.v_desired_filter.x, self.output_a_target, v_cruise)
|
||||
|
||||
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)
|
||||
if self.CP.openpilotLongitudinalControl:
|
||||
self.mpc.set_cur_state(self.v_desired_filter.x, self.output_a_target)
|
||||
else:
|
||||
self.mpc.set_cur_state(v_ego, sm['carState'].aEgo)
|
||||
self.mpc.update(sm['radarState'], personality=sm['selfdriveState'].personality)
|
||||
|
||||
self.v_desired_trajectory = np.interp(CONTROL_N_T_IDX, T_IDXS_MPC, self.mpc.v_solution)
|
||||
@@ -138,16 +142,18 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
output_a_target_e2e = sm['modelV2'].action.desiredAcceleration
|
||||
output_should_stop_e2e = sm['modelV2'].action.shouldStop
|
||||
|
||||
is_e2e = self.is_e2e(sm)
|
||||
experimental_mode = sm['selfdriveState'].deprecated.experimentalMode
|
||||
|
||||
self.a_cruise = get_cruise_accel(is_e2e, v_cruise, v_ego,
|
||||
self.a_cruise = get_cruise_accel(experimental_mode, 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)
|
||||
|
||||
output_a_target_e2e = self.accel_boost.update(sm, output_a_target_e2e, output_a_target_mpc, self.a_cruise)
|
||||
|
||||
candidates = [(output_a_target_mpc, self.mpc.source, output_should_stop_mpc),
|
||||
(self.a_cruise, LongitudinalPlanSource.cruise, cruise_should_stop)]
|
||||
if is_e2e:
|
||||
if experimental_mode:
|
||||
candidates.append((output_a_target_e2e, LongitudinalPlanSource.e2e, output_should_stop_e2e))
|
||||
|
||||
output_a_target, self.mpc.source, _ = min(candidates, key=lambda c: c[0])
|
||||
@@ -175,6 +181,7 @@ class LongitudinalPlanner(LongitudinalPlannerSP):
|
||||
longitudinalPlan.fcw = self.fcw
|
||||
|
||||
longitudinalPlan.aTarget = float(self.output_a_target)
|
||||
longitudinalPlan.accelBoost = float(self.accel_boost.total_boost)
|
||||
longitudinalPlan.shouldStop = bool(self.output_should_stop)
|
||||
longitudinalPlan.allowBrake = True
|
||||
longitudinalPlan.allowThrottle = bool(self.allow_throttle)
|
||||
|
||||
@@ -13,7 +13,7 @@ def desired_follow_distance(v_ego, v_lead, t_follow=None):
|
||||
t_follow = get_T_FOLLOW()
|
||||
return get_safe_obstacle_distance(v_ego, t_follow) - get_stopped_equivalence_factor(v_lead)
|
||||
|
||||
def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personality=0):
|
||||
def run_following_distance_simulation(v_lead, t_end=100.0, personality=0):
|
||||
man = Maneuver(
|
||||
'',
|
||||
duration=t_end,
|
||||
@@ -22,7 +22,6 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit
|
||||
initial_distance_lead=100,
|
||||
speed_lead_values=[v_lead],
|
||||
breakpoints=[0.],
|
||||
e2e=e2e,
|
||||
personality=personality,
|
||||
)
|
||||
valid, output = man.evaluate()
|
||||
@@ -30,8 +29,7 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit
|
||||
return output[-1,2] - output[-1,1]
|
||||
|
||||
|
||||
@parameterized_class(("e2e", "personality", "speed"), itertools.product(
|
||||
[True, False], # e2e
|
||||
@parameterized_class(("personality", "speed"), itertools.product(
|
||||
[log.LongitudinalPersonality.relaxed, # personality
|
||||
log.LongitudinalPersonality.standard,
|
||||
log.LongitudinalPersonality.aggressive],
|
||||
@@ -39,9 +37,8 @@ def run_following_distance_simulation(v_lead, t_end=100.0, e2e=False, personalit
|
||||
class TestFollowingDistance(OpenpilotTestCase):
|
||||
def test_following_distance(self):
|
||||
v_lead = float(self.speed)
|
||||
simulation_steady_state = run_following_distance_simulation(v_lead, e2e=self.e2e, personality=self.personality)
|
||||
simulation_steady_state = run_following_distance_simulation(v_lead, personality=self.personality)
|
||||
correct_steady_state = desired_follow_distance(v_lead, v_lead, get_T_FOLLOW(self.personality))
|
||||
err_ratio = 0.2 if self.e2e else 0.1
|
||||
abs_err_margin = 0.5 if v_lead > 0.0 else 1.15
|
||||
self.assertAlmostEqual(simulation_steady_state, correct_steady_state,
|
||||
delta=err_ratio * correct_steady_state + abs_err_margin)
|
||||
delta=0.2 * correct_steady_state + abs_err_margin)
|
||||
|
||||
@@ -8,12 +8,14 @@ from opendbc.car.honda.values import CAR as HONDA
|
||||
from opendbc.car.toyota.values import CAR as TOYOTA
|
||||
from opendbc.car.nissan.values import CAR as NISSAN
|
||||
from opendbc.car.gm.values import CAR as GM
|
||||
from opendbc.car.volkswagen.values import CAR as VOLKSWAGEN
|
||||
from opendbc.car.vehicle_model import VehicleModel
|
||||
from openpilot.common.realtime import DT_CTRL
|
||||
from openpilot.selfdrive.car.helpers import convert_to_capnp
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_pid import LatControlPID
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_torque import LatControlTorque
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_angle import LatControlAngle
|
||||
from openpilot.selfdrive.controls.lib.latcontrol_curvature import LatControlCurvature
|
||||
from openpilot.selfdrive.locationd.helpers import Pose
|
||||
from openpilot.common.mock.generators import generate_deviceMotion
|
||||
from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfaces
|
||||
@@ -22,7 +24,8 @@ from openpilot.sunnypilot.selfdrive.car import interfaces as sunnypilot_interfac
|
||||
class TestLatControl(OpenpilotTestCase):
|
||||
|
||||
@parameterized.expand([(HONDA.HONDA_CIVIC, LatControlPID), (TOYOTA.TOYOTA_RAV4, LatControlTorque),
|
||||
(NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque)])
|
||||
(NISSAN.NISSAN_LEAF, LatControlAngle), (GM.CHEVROLET_BOLT_EUV, LatControlTorque),
|
||||
(VOLKSWAGEN.VOLKSWAGEN_ID4_MK2, LatControlCurvature)])
|
||||
def test_saturation(self, car_name, controller):
|
||||
CarInterface = interfaces[car_name]
|
||||
CP = CarInterface.get_non_essential_params(car_name)
|
||||
|
||||
@@ -80,7 +80,6 @@ class CarKalman(KalmanFilter):
|
||||
}
|
||||
|
||||
global_vars = [
|
||||
'mass',
|
||||
'rotational_inertia',
|
||||
'center_to_front',
|
||||
'center_to_rear',
|
||||
@@ -99,7 +98,10 @@ class CarKalman(KalmanFilter):
|
||||
|
||||
# globals
|
||||
global_vars = [sp.Symbol(name) for name in CarKalman.global_vars]
|
||||
m, j, aF, aR, cF_orig, cR_orig = global_vars
|
||||
j, aF, aR, cF_orig, cR_orig = global_vars
|
||||
# Tire stiffness and rotational inertia are already divided by vehicle mass, which cancels out of the model.
|
||||
# Use unit mass to preserve the standard bicycle-model equations below.
|
||||
m = 1
|
||||
|
||||
# make functions and jacobians with sympy
|
||||
# state variables
|
||||
@@ -166,8 +168,7 @@ class CarKalman(KalmanFilter):
|
||||
self.filter = EKF_sym_pyx(generated_dir, CarKalman.name, CarKalman.Q, CarKalman.initial_x, CarKalman.P_initial,
|
||||
dim_state, dim_state_err, global_vars=CarKalman.global_vars, logger=cloudlog)
|
||||
|
||||
def set_globals(self, mass, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear):
|
||||
self.filter.set_global("mass", mass)
|
||||
def set_globals(self, rotational_inertia, center_to_front, center_to_rear, stiffness_front, stiffness_rear):
|
||||
self.filter.set_global("rotational_inertia", rotational_inertia)
|
||||
self.filter.set_global("center_to_front", center_to_front)
|
||||
self.filter.set_global("center_to_rear", center_to_rear)
|
||||
|
||||
@@ -40,7 +40,7 @@ class PoseKalman(KalmanFilter):
|
||||
initial_P = np.diag([0.01**2, 0.01**2, 0.01**2,
|
||||
10**2, 10**2, 10**2,
|
||||
1**2, 1**2, 1**2,
|
||||
1**2, 1**2, 1**2,
|
||||
0.1**2, 0.1**2, 0.1**2,
|
||||
100**2, 100**2, 100**2,
|
||||
0.01**2, 0.01**2, 0.01**2])
|
||||
|
||||
|
||||
@@ -36,12 +36,11 @@ class VehicleParamsLearner:
|
||||
self.P_initial = P_initial if P_initial is not None else CarKalman.P_initial
|
||||
|
||||
self.kf.set_globals(
|
||||
mass=CP.mass,
|
||||
rotational_inertia=CP.rotationalInertia,
|
||||
rotational_inertia=CP.rotationalInertiaPerMass,
|
||||
center_to_front=CP.centerToFront,
|
||||
center_to_rear=CP.wheelbase - CP.centerToFront,
|
||||
stiffness_front=CP.tireStiffnessFront,
|
||||
stiffness_rear=CP.tireStiffnessRear
|
||||
stiffness_front=CP.tireStiffnessFrontPerMass,
|
||||
stiffness_rear=CP.tireStiffnessRearPerMass
|
||||
)
|
||||
|
||||
self.min_sr, self.max_sr = 0.5 * CP.steerRatio, 2.0 * CP.steerRatio
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import ctypes.util
|
||||
import glob
|
||||
import os
|
||||
import time
|
||||
from SCons.Script import Action, Value
|
||||
from openpilot.common.transformations.camera import _ar_ox_fisheye, _os_fisheye
|
||||
from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE, DM_INPUT_SIZE
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
|
||||
|
||||
Import('env', 'arch')
|
||||
|
||||
# TODO: add GCC support to tinygrad?
|
||||
# tinygrad's CPU:LLVM backend requires libLLVM, skip the build on PC if it's not available
|
||||
if arch not in ('comma_arm64', 'Darwin') and ctypes.util.find_library('LLVM') is None:
|
||||
print("\033[93mmodeld: LLVM not found, skipping tinygrad model compilation. Install LLVM to compile models.\033[0m")
|
||||
Return()
|
||||
|
||||
lenv = env.Clone()
|
||||
lenv.PrependENVPath('PYTHONPATH', Dir('#tinygrad_repo').abspath)
|
||||
|
||||
@@ -24,38 +30,18 @@ else:
|
||||
# JIT=2 disables graph batching, which produces incorrect outputs after buffers change.
|
||||
tg_flags = 'DEV=METAL JIT=2' if arch == 'Darwin' else 'DEV=CPU:LLVM'
|
||||
|
||||
CHESTNUT = chestnut_present()
|
||||
if CHESTNUT:
|
||||
chestnut_tg_flags = 'DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32'
|
||||
# the USB+AMD GPU takes an exclusive flock; serialize all targets that touch it
|
||||
chestnut_lock = File("models/.chestnut.lock").abspath
|
||||
|
||||
# tinygrad calls brew which needs a $HOME in the env
|
||||
mac_brew_string = f'HOME={os.path.expanduser("~")}' if arch == 'Darwin' else ''
|
||||
|
||||
warp_deps = [File("#openpilot/system/camerad/cameras/nv12_info.py")]
|
||||
compiler = Dir('#tinygrad_repo/examples/openpilot').abspath
|
||||
# CPU 7 is isolated with isolcpus on AGNOS, so explicitly pin the compiler to it.
|
||||
taskset = 'taskset -c 7 ' if arch == 'comma_arm64' else ''
|
||||
|
||||
def chestnut_action(command):
|
||||
def do_compile(target, source, env):
|
||||
from openpilot.system.hardware.chestnut.flash import link_up
|
||||
# chestnut can enumerate before its PCIe link is up due to varying 12V power behavior across cars
|
||||
for _ in range(10):
|
||||
if link_up():
|
||||
break
|
||||
time.sleep(1)
|
||||
else:
|
||||
print("Chestnut not ready, skipping warp build")
|
||||
return
|
||||
return env.Execute(command)
|
||||
return Action(do_compile, " [CHESTNUT] $TARGET")
|
||||
# build on all cores
|
||||
taskset = 'taskset -c 0-7 ' if arch == 'comma_arm64' else ''
|
||||
|
||||
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)],
|
||||
@@ -66,19 +52,14 @@ compile_model('models/dmonitoring_model.onnx', 'models/dmonitoring_model_tinygra
|
||||
compile_model('models/driving_supercombo.onnx', 'models/driving_tinygrad.pkl')
|
||||
|
||||
model_w, model_h = MEDMODEL_INPUT_SIZE
|
||||
for chestnut in [False, True] if CHESTNUT else [False]:
|
||||
file_prefix, cmd_flags = ('big_', chestnut_tg_flags) if chestnut else ('', tg_flags)
|
||||
for cam_w, cam_h in camera_configs:
|
||||
warp_pkl_path = File(f"models/{file_prefix}driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
|
||||
stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h)
|
||||
cmd = (f'{cmd_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" '
|
||||
f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} '
|
||||
f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 '
|
||||
f'--output {warp_pkl_path}')
|
||||
action = chestnut_action(cmd) if chestnut else cmd
|
||||
node = lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], action)
|
||||
if chestnut:
|
||||
lenv.SideEffect(chestnut_lock, node)
|
||||
for cam_w, cam_h in camera_configs:
|
||||
warp_pkl_path = File(f"models/driving_warp_{cam_w}x{cam_h}_tinygrad.pkl").abspath
|
||||
stride, y_height, uv_height, _ = get_nv12_info(cam_w, cam_h)
|
||||
cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_warp.py" '
|
||||
f'--frame {cam_w},{cam_h},{stride},{y_height},{uv_height},{stride * (y_height + uv_height)} '
|
||||
f'--warp-to {model_w}x{model_h} --layout yuv420 --frames 2 '
|
||||
f'--output {warp_pkl_path}')
|
||||
lenv.Command(warp_pkl_path, tinygrad_files + warp_deps + [Value(cmd)], cmd)
|
||||
|
||||
dm_w, dm_h = DM_INPUT_SIZE
|
||||
for cam_w, cam_h in camera_configs:
|
||||
|
||||
@@ -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,13 @@
|
||||
import io
|
||||
import os
|
||||
import pickle
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
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 +17,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("*"):
|
||||
@@ -34,6 +42,14 @@ def chestnut_present() -> bool:
|
||||
pass
|
||||
return False
|
||||
|
||||
def wait_for_chestnut(timeout: float = 10.) -> None:
|
||||
# the cable is detected before chestnut enumerates
|
||||
st = time.monotonic()
|
||||
while not chestnut_present():
|
||||
if time.monotonic() - st > timeout:
|
||||
raise TimeoutError("chestnut did not enumerate")
|
||||
time.sleep(0.1)
|
||||
|
||||
def chestnut_compiled() -> bool:
|
||||
path = modeld_pkl_path(chestnut=True)
|
||||
return (path.is_file() or Path(get_manifest_path(path)).is_file()) and all(
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
from collections.abc import Callable
|
||||
import base64
|
||||
import ctypes
|
||||
import gc
|
||||
from functools import cached_property
|
||||
import os
|
||||
os.environ['GMMU'] = '0' # for chestnut fast loading, noop for qcom
|
||||
os.environ.setdefault('AM_POWER_LIMIT', '100')
|
||||
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
|
||||
@@ -25,6 +28,7 @@ from msgq.visionipc import VisionIpcClient, VisionBuf
|
||||
from opendbc.car.car_helpers import get_demo_car_params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.hardware.usb import cable_connected
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
from openpilot.common.realtime import config_realtime_process, DT_MDL
|
||||
from openpilot.common.transformations.camera import DEVICE_CAMERAS
|
||||
@@ -34,9 +38,8 @@ 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.selfdrive.modeld.helpers import MODELS_DIR, chestnut_present, chestnut_compiled, modeld_pkl_path, load_oob, wait_for_chestnut
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
@@ -134,8 +137,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 +146,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 +161,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]
|
||||
@@ -203,6 +208,8 @@ class ModelState(ModelStateBase):
|
||||
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)
|
||||
if self.chestnut and (err := Device[self.outputs['outputs'].device].error_state.host.view(fmt='q')[0]):
|
||||
raise RuntimeError(f"chestnut failed with {err}")
|
||||
if after_enqueue is not None:
|
||||
after_enqueue()
|
||||
model_output = self.outputs['outputs'].numpy()[0]
|
||||
@@ -228,14 +235,15 @@ class ModelState(ModelStateBase):
|
||||
def main(demo=False):
|
||||
cloudlog.warning("modeld init")
|
||||
|
||||
CHESTNUT = chestnut_present() and chestnut_compiled()
|
||||
CHESTNUT = chestnut_compiled() and (chestnut_present() or cable_connected())
|
||||
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")
|
||||
|
||||
config_realtime_process(7, 54)
|
||||
gc.disable()
|
||||
|
||||
# visionipc clients
|
||||
while True:
|
||||
@@ -268,6 +276,7 @@ def main(demo=False):
|
||||
def load_big():
|
||||
nonlocal big_model
|
||||
try:
|
||||
wait_for_chestnut()
|
||||
m = ModelState(vipc_client_main.width, vipc_client_main.height, True)
|
||||
m.warmup()
|
||||
big_model = m
|
||||
@@ -277,7 +286,13 @@ def main(demo=False):
|
||||
loader.start()
|
||||
loader.join(BIG_MODEL_TIMEOUT)
|
||||
model = big_model
|
||||
params.put_bool("ChestnutActive", model is not None)
|
||||
if model is None and not params.get_bool("ChestnutRetry"):
|
||||
# a crank can cut the chestnut power during the load, retry once with an onroad cycle
|
||||
cloudlog.error("big model load failed, cycling onroad to retry")
|
||||
params.put_bool("ChestnutRetry", True)
|
||||
params.put_bool("OnroadCycleRequested", True)
|
||||
else:
|
||||
params.put_bool("ChestnutActive", model is not None)
|
||||
|
||||
small_model = ModelState(vipc_client_main.width, vipc_client_main.height, False) if model is None or CHESTNUT else None
|
||||
if model is None:
|
||||
@@ -286,10 +301,13 @@ def main(demo=False):
|
||||
params.put_bool("ChestnutLoading", False)
|
||||
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
|
||||
|
||||
config_realtime_process(7, 54)
|
||||
|
||||
# messaging
|
||||
pub_socks = ["modelV2", "drivingModelData", "cameraOdometry", "modelDataV2SP"] + (["chestnutGpuState"] if CHESTNUT else [])
|
||||
pm = PubMaster(pub_socks)
|
||||
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState", "carControl", "lateralDelay"])
|
||||
sm = SubMaster(["deviceState", "carState", "narrowRoadCameraState", "extrinsicsCalibration", "driverMonitoringState",
|
||||
"carControl", "controlsState", "lateralDelay"])
|
||||
|
||||
publish_state = PublishState()
|
||||
params = Params()
|
||||
@@ -407,7 +425,7 @@ def main(demo=False):
|
||||
run_count % round(ModelConstants.MODEL_RUN_FREQ / SERVICE_LIST['chestnutGpuState'].frequency) == 0)
|
||||
model_output = model.run(bufs, transforms, inputs, chestnut_state.send if send_chestnut else None)
|
||||
except Exception:
|
||||
if not params.get_bool("ChestnutActive"):
|
||||
if not model.chestnut:
|
||||
raise
|
||||
# fallback to small model
|
||||
cloudlog.exception("big model failed, fall back to small")
|
||||
@@ -427,6 +445,8 @@ def main(demo=False):
|
||||
posenet_send = messaging.new_message('cameraOdometry')
|
||||
mdv2sp_send = messaging.new_message('modelDataV2SP')
|
||||
|
||||
if not sm['carControl'].latActive:
|
||||
prev_action.desiredCurvature = sm['controlsState'].curvature
|
||||
action = get_action_from_model(model_output, prev_action, lat_action_t, long_action_t, v_ego)
|
||||
prev_action = action
|
||||
fill_model_msg(modelv2_send, model_output, action,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:76cc0a9bc3af7a318889483dcbe126337f8d338f5abcbe664a8988c9b18b6639
|
||||
size 776634338
|
||||
oid sha256:d4c7a3fcfc95813826ca871a80bea18df833aacb2eb6f5333e25a01e65b1a422
|
||||
size 799941965
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:18c994e4500fe9f34daa8f33202a8606d3817e09e2c79ae8f9795e37159b5143
|
||||
size 850515
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e30b5cada2c2a861999de6103c42053fae524850e30d63cd8f4d9e9bdd319e54
|
||||
size 850258
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dd299afabe7a3e0d04cbe2bd97fdb0c93bba8ad6d3cc3663a0e0ededaf243ac2
|
||||
size 7497335
|
||||
oid sha256:dee5a294e8afaacc9295ac5d100e00733ecac278e79264b96331e40a3ede1b04
|
||||
size 7844499
|
||||
|
||||
@@ -51,6 +51,7 @@ class DRIVER_MONITOR_SETTINGS:
|
||||
self._SG_THRESHOLD = 0.9
|
||||
self._BLINK_THRESHOLD = 0.865
|
||||
self._PHONE_THRESH = 0.5
|
||||
self._SLEEP_THRESH = 0.75
|
||||
self._POSE_PITCH_THRESHOLD = 0.3133
|
||||
self._POSE_PITCH_THRESHOLD_SLACK = 0.3237
|
||||
self._POSE_PITCH_THRESHOLD_STRICT = self._POSE_PITCH_THRESHOLD
|
||||
@@ -138,6 +139,7 @@ class DriverMonitoring:
|
||||
self.pose = DriverPose(settings=self.settings)
|
||||
self.blink = DriverBlink()
|
||||
self.phone_prob = 0.
|
||||
self.sleep_prob = 0.
|
||||
|
||||
self.alert_level = AlertLevel.none
|
||||
self.always_on = always_on
|
||||
@@ -240,6 +242,7 @@ class DriverMonitoring:
|
||||
self.distracted_types['pose'] = bool((pitch_error > pitch_threshold) or (yaw_error > yaw_threshold))
|
||||
self.distracted_types['eye'] = bool((self.blink.left + self.blink.right)*0.5 > self.settings._BLINK_THRESHOLD)
|
||||
self.distracted_types['phone'] = bool(self.phone_prob > self.settings._PHONE_THRESH)
|
||||
self.distracted_types['sleep'] = bool(self.sleep_prob > self.settings._SLEEP_THRESH)
|
||||
|
||||
def _update_states(self, driver_state, cal_rpy, car_speed, op_engaged, lowspeed, demo_mode=False, steering_angle_deg=0.):
|
||||
rhd_pred = driver_state.wheelOnRightProb
|
||||
@@ -277,6 +280,7 @@ class DriverMonitoring:
|
||||
self.blink.right = driver_data.rightBlinkProb * (driver_data.rightEyeProb > self.settings._EYE_THRESHOLD) \
|
||||
* (driver_data.sunglassesProb < self.settings._SG_THRESHOLD)
|
||||
self.phone_prob = driver_data.phoneProb
|
||||
self.sleep_prob = driver_data.sleepProb
|
||||
|
||||
self._get_distracted_types()
|
||||
self.driver_distracted = any(self.distracted_types.values()) and driver_data.faceProb > self.settings._FACE_THRESHOLD and self.pose.low_std
|
||||
@@ -406,6 +410,7 @@ class DriverMonitoring:
|
||||
dm.visionPolicyState.distractedTypes.pose = self.distracted_types['pose']
|
||||
dm.visionPolicyState.distractedTypes.eye = self.distracted_types['eye']
|
||||
dm.visionPolicyState.distractedTypes.phone = self.distracted_types['phone']
|
||||
dm.visionPolicyState.distractedTypes.sleep = self.distracted_types['sleep']
|
||||
dm.visionPolicyState.faceDetected = self.face_detected
|
||||
dm.visionPolicyState.pose.pitch = self.pose.pitch
|
||||
dm.visionPolicyState.pose.yaw = self.pose.yaw
|
||||
|
||||
@@ -28,6 +28,7 @@ def make_msg(face_detected, distracted=False, model_uncertain=False):
|
||||
ds.leftDriverData.facePositionStd = [1.*model_uncertain, 1.*model_uncertain]
|
||||
# TODO: test both separately when e2e is used
|
||||
ds.leftDriverData.phoneProb = 0.
|
||||
ds.leftDriverData.sleepProb = 0.
|
||||
return ds
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import time
|
||||
|
||||
|
||||
def latest_gps_source(sm) -> str | None:
|
||||
"""The service with the freshest usable GPS fix, or None."""
|
||||
now = time.monotonic()
|
||||
sources = [s for s in ('gpsLocation', 'gpsLocationExternal')
|
||||
if sm.valid[s] and sm[s].hasFix and 0 <= now - sm.recv_time[s] < 2]
|
||||
return max(sources, key=lambda s: sm.logMonoTime[s]) if sources else None
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from msgq.visionipc import VisionIpcServer
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.navigation.helpers import latest_gps_source
|
||||
from openpilot.selfdrive.navigation.pmtiles import PMTiles
|
||||
from openpilot.selfdrive.navigation.renderer import SIZE, MapRenderer
|
||||
from openpilot.selfdrive.navigation.route import RouteTracker
|
||||
from openpilot.selfdrive.navigation.tiles import RemoteTiles
|
||||
|
||||
RETRY_SECONDS = 10
|
||||
|
||||
|
||||
class MapsD:
|
||||
def __init__(self):
|
||||
self.renderer = None
|
||||
self.tracker = None
|
||||
self.route = np.empty((0, 2))
|
||||
self.prefetched = False
|
||||
self.retry_at = 0.0
|
||||
|
||||
def update(self, sm) -> tuple[np.ndarray, int]:
|
||||
"""The map frame for the latest messages: black with a zero timestamp without a usable map, never stale."""
|
||||
if sm.updated['navRoute']:
|
||||
route = np.array([(c.longitude, c.latitude) for c in sm['navRoute'].coordinates], dtype=np.float64).reshape((-1, 2))
|
||||
if not np.array_equal(route, self.route):
|
||||
self.route = route
|
||||
self.tracker = RouteTracker(route) if len(route) >= 2 else None # progress along the route starts over
|
||||
self.prefetched = False
|
||||
now = time.monotonic()
|
||||
source = latest_gps_source(sm)
|
||||
try:
|
||||
if self.tracker is not None and now >= self.retry_at:
|
||||
if self.renderer is None:
|
||||
self.renderer = MapRenderer(PMTiles(RemoteTiles()))
|
||||
if not self.prefetched:
|
||||
# TODO: fetch the tiles more intelligently on demand
|
||||
# Every tile the route can show is fetched up front, so each render below is a cache hit.
|
||||
self.renderer.prefetch(self.route)
|
||||
self.prefetched = True
|
||||
if source is not None:
|
||||
gps = sm[source]
|
||||
# As in training, the map is centered on the route and headed along it, not at the GPS fix.
|
||||
image, valid = self.renderer.render(*self.tracker.snap(gps.longitude, gps.latitude), self.route)
|
||||
if valid:
|
||||
return image, sm.logMonoTime[source]
|
||||
except Exception: # a failed fetch, or a corrupt cached tile
|
||||
cloudlog.exception('mapsd.render_failed')
|
||||
self.close() # a changed archive needs a fresh reader
|
||||
self.retry_at = now + RETRY_SECONDS
|
||||
return np.zeros((SIZE, SIZE), dtype=np.uint8), 0
|
||||
|
||||
def close(self):
|
||||
if self.renderer is not None:
|
||||
self.renderer.archive.close()
|
||||
self.renderer = None
|
||||
self.prefetched = False
|
||||
|
||||
|
||||
def main():
|
||||
rk = Ratekeeper(20.0)
|
||||
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute'])
|
||||
server = VisionIpcServer('mapsd')
|
||||
server.create_buffers_with_sizes(VisionStreamType.VISION_STREAM_MAP, 4, SIZE, SIZE, SIZE * SIZE, SIZE, SIZE * SIZE)
|
||||
server.start_listener()
|
||||
mapsd = MapsD()
|
||||
try:
|
||||
while True:
|
||||
sm.update(0)
|
||||
image, timestamp = mapsd.update(sm)
|
||||
server.send(VisionStreamType.VISION_STREAM_MAP, image.tobytes(), rk.frame, timestamp, time.monotonic_ns())
|
||||
rk.keep_time()
|
||||
finally:
|
||||
mapsd.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.common.api import API_HOST, Api
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.navigation.helpers import latest_gps_source
|
||||
from openpilot.selfdrive.navigation.route import along_path, local_meters
|
||||
|
||||
REROUTE_DISTANCE = 25
|
||||
MANEUVER_TRANSITION_THRESHOLD = 10
|
||||
REROUTE_COUNTER_MIN = 3
|
||||
ROUTE_RESEND_PERIOD = 10 # updates (seconds); a restarted mapsd gets the route within this
|
||||
RECOMPUTE_HOLD = 10 # updates (seconds) between route requests
|
||||
|
||||
|
||||
def set_banner(instruction, banners, distance_to_maneuver):
|
||||
"""Fill the banner fields from the banner to show now: the last one still ahead of the ego, else the first."""
|
||||
if not banners:
|
||||
return
|
||||
banner = next((b for b in reversed(banners) if distance_to_maneuver < b['distanceAlongGeometry']), banners[0])
|
||||
instruction.showFull = distance_to_maneuver < banner['distanceAlongGeometry']
|
||||
primary = banner['primary']
|
||||
instruction.maneuverPrimaryText = primary.get('text') or ''
|
||||
instruction.maneuverType = primary.get('type') or ''
|
||||
instruction.maneuverModifier = primary.get('modifier') or ''
|
||||
if banner.get('secondary') is not None:
|
||||
instruction.maneuverSecondaryText = banner['secondary']['text']
|
||||
|
||||
|
||||
class RouteEngine:
|
||||
def __init__(self, sm, pm, params=None):
|
||||
self.sm = sm
|
||||
self.pm = pm
|
||||
self.params = params if params is not None else Params()
|
||||
|
||||
self.last_position = None # (longitude, latitude)
|
||||
self.last_bearing = None
|
||||
|
||||
self.nav_destination = None
|
||||
self.step_idx = None
|
||||
self.route = None
|
||||
self.route_geometry = None # an (n, 2) array of (longitude, latitude) per step
|
||||
|
||||
self.recompute_hold = 0
|
||||
self.reroute_counter = 0
|
||||
|
||||
self.mapbox_token = os.environ.get("MAPBOX_TOKEN")
|
||||
self.api = Api(self.params.get("DongleId")) if self.mapbox_token is None else None
|
||||
self.mapbox_host = "https://api.mapbox.com" if self.mapbox_token is not None else API_HOST.rstrip('/') + '/v1/nav'
|
||||
|
||||
def update(self):
|
||||
self.sm.update(0)
|
||||
|
||||
source = latest_gps_source(self.sm)
|
||||
if source is not None:
|
||||
gps = self.sm[source]
|
||||
self.last_position, self.last_bearing = (gps.longitude, gps.latitude), gps.bearingDeg
|
||||
route = self.route
|
||||
try:
|
||||
self.recompute_route(gps_ok=source is not None)
|
||||
self.send_instruction()
|
||||
except Exception:
|
||||
cloudlog.exception("navd.failed_to_compute")
|
||||
# mapsd dedupes repeats, so a periodic resend is how a restarted mapsd gets the route
|
||||
if self.route is not route or self.sm.frame % ROUTE_RESEND_PERIOD == 0:
|
||||
self.send_route()
|
||||
|
||||
def recompute_route(self, gps_ok: bool):
|
||||
if self.last_position is None:
|
||||
return
|
||||
|
||||
destination = self.params.get("NavDestination")
|
||||
if destination is None:
|
||||
self.clear_route()
|
||||
self.recompute_hold = 0
|
||||
return
|
||||
new_destination = (destination['longitude'], destination['latitude'])
|
||||
|
||||
# Don't recompute when GPS drifts in tunnels
|
||||
if not gps_ok and self.step_idx is not None:
|
||||
return
|
||||
|
||||
self.recompute_hold -= 1
|
||||
if (self.should_recompute() or new_destination != self.nav_destination) and self.recompute_hold <= 0:
|
||||
self.recompute_hold = RECOMPUTE_HOLD
|
||||
self.calculate_route(new_destination)
|
||||
self.reroute_counter = 0
|
||||
|
||||
def calculate_route(self, destination):
|
||||
assert self.last_position is not None
|
||||
cloudlog.warning(f"Calculating route {self.last_position} -> {destination}")
|
||||
self.nav_destination = destination
|
||||
|
||||
lang = self.params.get('LanguageSetting')
|
||||
if lang is not None:
|
||||
lang = lang.replace('main_', '')
|
||||
|
||||
token = self.mapbox_token
|
||||
if token is None:
|
||||
token = self.api.get_token()
|
||||
|
||||
params = {
|
||||
'access_token': token,
|
||||
'geometries': 'geojson',
|
||||
'overview': 'full',
|
||||
'steps': 'true',
|
||||
'banner_instructions': 'true',
|
||||
'alternatives': 'false',
|
||||
'language': lang,
|
||||
}
|
||||
if self.last_bearing is not None:
|
||||
params['bearings'] = f"{(self.last_bearing + 360) % 360:.0f},90;"
|
||||
|
||||
coords_str = ';'.join(f'{lon},{lat}' for lon, lat in (self.last_position, destination))
|
||||
url = self.mapbox_host + '/directions/v5/mapbox/driving-traffic/' + coords_str
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
cloudlog.event("API request failed", status_code=resp.status_code, text=resp.text, error=True)
|
||||
resp.raise_for_status()
|
||||
|
||||
r = resp.json()
|
||||
if len(r['routes']):
|
||||
self.route = r['routes'][0]['legs'][0]['steps']
|
||||
self.route_geometry = [np.array(step['geometry']['coordinates'], dtype=np.float64).reshape((-1, 2)) for step in self.route]
|
||||
self.step_idx = 0
|
||||
else:
|
||||
cloudlog.warning("Got empty route response")
|
||||
self.clear_route()
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
cloudlog.exception("failed to get route")
|
||||
self.clear_route()
|
||||
|
||||
def send_instruction(self):
|
||||
msg = messaging.new_message('navInstruction', valid=True)
|
||||
|
||||
if self.step_idx is None:
|
||||
msg.valid = False
|
||||
self.pm.send('navInstruction', msg)
|
||||
return
|
||||
|
||||
assert self.route is not None and self.route_geometry is not None and self.last_position is not None
|
||||
step = self.route[self.step_idx]
|
||||
along_geometry, _ = along_path(self.route_geometry[self.step_idx], self.last_position)
|
||||
distance_to_maneuver_along_geometry = step['distance'] - along_geometry
|
||||
|
||||
# Banner instructions are for the following maneuver step, don't use empty last step
|
||||
banner_step = step
|
||||
if not len(banner_step['bannerInstructions']) and self.step_idx == len(self.route) - 1:
|
||||
banner_step = self.route[max(self.step_idx - 1, 0)]
|
||||
|
||||
# Current instruction
|
||||
msg.navInstruction.maneuverDistance = distance_to_maneuver_along_geometry
|
||||
set_banner(msg.navInstruction, banner_step['bannerInstructions'], distance_to_maneuver_along_geometry)
|
||||
|
||||
# Total remaining distance and time: the rest of this step, then every step after it
|
||||
remaining = 1.0 - along_geometry / max(step['distance'], 1)
|
||||
future = self.route[self.step_idx + 1:]
|
||||
msg.navInstruction.distanceRemaining = step['distance'] * remaining + sum(s['distance'] for s in future)
|
||||
msg.navInstruction.timeRemaining = step['duration'] * remaining + sum(s['duration'] for s in future)
|
||||
|
||||
self.pm.send('navInstruction', msg)
|
||||
|
||||
# Transition to next route segment
|
||||
if distance_to_maneuver_along_geometry < -MANEUVER_TRANSITION_THRESHOLD:
|
||||
if self.step_idx + 1 < len(self.route):
|
||||
self.step_idx += 1
|
||||
self.recompute_hold = 0
|
||||
else:
|
||||
cloudlog.warning("Destination reached")
|
||||
|
||||
# Clear route if driving away from destination
|
||||
if np.linalg.norm(local_meters(self.last_position, self.nav_destination)) > REROUTE_DISTANCE:
|
||||
self.params.remove("NavDestination")
|
||||
self.clear_route()
|
||||
|
||||
def send_route(self):
|
||||
coords = np.concatenate(self.route_geometry) if self.route_geometry is not None else np.empty((0, 2))
|
||||
msg = messaging.new_message('navRoute', valid=True)
|
||||
msg.navRoute.coordinates = [{'longitude': lon, 'latitude': lat} for lon, lat in coords.tolist()]
|
||||
self.pm.send('navRoute', msg)
|
||||
|
||||
def clear_route(self):
|
||||
self.route = None
|
||||
self.route_geometry = None
|
||||
self.step_idx = None
|
||||
self.nav_destination = None
|
||||
|
||||
def should_recompute(self):
|
||||
if self.step_idx is None:
|
||||
return True
|
||||
|
||||
assert self.route is not None and self.route_geometry is not None and self.last_position is not None
|
||||
# Don't recompute in last segment, assume destination is reached
|
||||
if self.step_idx == len(self.route) - 1:
|
||||
return False
|
||||
|
||||
# TODO: Check for going wrong way in segment
|
||||
if along_path(self.route_geometry[self.step_idx], self.last_position)[1] > REROUTE_DISTANCE:
|
||||
self.reroute_counter += 1
|
||||
else:
|
||||
self.reroute_counter = 0
|
||||
return self.reroute_counter > REROUTE_COUNTER_MIN
|
||||
|
||||
|
||||
def main():
|
||||
pm = messaging.PubMaster(['navInstruction', 'navRoute'])
|
||||
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal'])
|
||||
|
||||
rk = Ratekeeper(1.0)
|
||||
route_engine = RouteEngine(sm, pm)
|
||||
while True:
|
||||
route_engine.update()
|
||||
rk.keep_time()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""PMTiles v3 reader for the gzip-compressed Protomaps MVT basemap, over any byte-range source."""
|
||||
import gzip
|
||||
import struct
|
||||
from bisect import bisect_right
|
||||
from functools import lru_cache
|
||||
|
||||
HEADER_LENGTH = 127
|
||||
|
||||
|
||||
def read_varint(data: bytes, offset: int) -> tuple[int, int]:
|
||||
value = 0
|
||||
for shift in range(0, 70, 7):
|
||||
if offset >= len(data):
|
||||
raise ValueError("Truncated varint")
|
||||
byte = data[offset]
|
||||
offset += 1
|
||||
value |= (byte & 127) << shift
|
||||
if byte < 128:
|
||||
return value, offset
|
||||
raise ValueError("Varint exceeds 64 bits")
|
||||
|
||||
|
||||
def tile_id(z: int, x: int, y: int) -> int:
|
||||
"""PMTiles orders each zoom level along a Hilbert curve."""
|
||||
if not 0 <= z <= 26 or not 0 <= x < 1 << z or not 0 <= y < 1 << z:
|
||||
raise ValueError("Invalid tile coordinates")
|
||||
distance = 0
|
||||
size = (1 << z) // 2
|
||||
while size:
|
||||
rx, ry = int(bool(x & size)), int(bool(y & size))
|
||||
distance += size * size * ((3 * rx) ^ ry)
|
||||
if not ry:
|
||||
if rx:
|
||||
x, y = size - 1 - x, size - 1 - y
|
||||
x, y = y, x
|
||||
size //= 2
|
||||
return ((1 << (2 * z)) - 1) // 3 + distance
|
||||
|
||||
|
||||
def decode_directory(data: bytes) -> tuple[list[int], list[tuple[int, int, int]]]:
|
||||
count, offset = read_varint(data, 0)
|
||||
if count > len(data):
|
||||
raise ValueError("Invalid directory entry count")
|
||||
columns = []
|
||||
for _ in range(4):
|
||||
column = []
|
||||
for _ in range(count):
|
||||
value, offset = read_varint(data, offset)
|
||||
column.append(value)
|
||||
columns.append(column)
|
||||
ids, runs, lengths, offsets = columns
|
||||
for i in range(count):
|
||||
if i:
|
||||
ids[i] += ids[i - 1]
|
||||
if offsets[i] == 0:
|
||||
if i == 0:
|
||||
raise ValueError("First directory offset cannot be implicit")
|
||||
offsets[i] = offsets[i - 1] + lengths[i - 1]
|
||||
else:
|
||||
offsets[i] -= 1
|
||||
if lengths[i] == 0 or (i and ids[i] <= ids[i - 1]):
|
||||
raise ValueError("Invalid directory entry")
|
||||
if offset != len(data):
|
||||
raise ValueError("Trailing directory data")
|
||||
return ids, list(zip(runs, lengths, offsets, strict=True))
|
||||
|
||||
|
||||
class PMTiles:
|
||||
def __init__(self, source):
|
||||
"""source has size, read(offset, length), prefetch(ranges) and close()."""
|
||||
self.source = source
|
||||
self.size = source.size
|
||||
try:
|
||||
header = self.read(0, HEADER_LENGTH)
|
||||
if header[:8] != b'PMTiles\x03':
|
||||
raise ValueError("Expected a PMTiles v3 archive")
|
||||
self.root_offset, self.root_length, _, _, self.leaf_offset, self.leaf_length, self.data_offset, self.data_length = struct.unpack_from('<8Q', header, 8)
|
||||
compression, tile_type, self.min_zoom, self.max_zoom = header[97:99], header[99], header[100], header[101]
|
||||
if compression != b'\x02\x02' or tile_type != 1:
|
||||
raise ValueError("Expected gzip-compressed MVT vector tiles")
|
||||
if not 0 <= self.min_zoom <= self.max_zoom <= 26:
|
||||
raise ValueError("Unsupported PMTiles zoom range")
|
||||
for offset, length in ((self.root_offset, self.root_length), (self.leaf_offset, self.leaf_length), (self.data_offset, self.data_length)):
|
||||
if offset + length > self.size:
|
||||
raise ValueError("PMTiles section outside archive")
|
||||
self.directory = lru_cache(maxsize=32)(self._read_directory)
|
||||
self.directory(self.root_offset, self.root_length)
|
||||
except Exception:
|
||||
self.source.close()
|
||||
raise
|
||||
|
||||
def read(self, offset: int, length: int) -> bytes:
|
||||
if offset < 0 or length < 0 or offset + length > self.size:
|
||||
raise ValueError("PMTiles read outside archive")
|
||||
data = self.source.read(offset, length)
|
||||
if len(data) != length:
|
||||
raise ValueError("Truncated PMTiles archive")
|
||||
return data
|
||||
|
||||
def _read_directory(self, offset: int, length: int):
|
||||
return decode_directory(gzip.decompress(self.read(offset, length)))
|
||||
|
||||
def locate(self, z: int, x: int, y: int) -> tuple[int, int] | None:
|
||||
"""The byte range of a tile in the archive, or None when there is no such tile."""
|
||||
if not self.min_zoom <= z <= self.max_zoom:
|
||||
return None
|
||||
target = tile_id(z, x, y)
|
||||
offset, length = self.root_offset, self.root_length
|
||||
for _ in range(4):
|
||||
ids, entries = self.directory(offset, length)
|
||||
index = bisect_right(ids, target) - 1
|
||||
if index < 0:
|
||||
return None
|
||||
run, length, offset = entries[index]
|
||||
if run:
|
||||
if target >= ids[index] + run:
|
||||
return None
|
||||
if offset + length > self.data_length:
|
||||
raise ValueError("Tile outside PMTiles data section")
|
||||
return self.data_offset + offset, length
|
||||
if offset + length > self.leaf_length:
|
||||
raise ValueError("Directory outside PMTiles leaf section")
|
||||
offset += self.leaf_offset
|
||||
raise ValueError("PMTiles directory nesting exceeds four levels")
|
||||
|
||||
def get(self, z: int, x: int, y: int) -> bytes | None:
|
||||
located = self.locate(z, x, y)
|
||||
return gzip.decompress(self.read(*located)) if located else None
|
||||
|
||||
def prefetch(self, tiles):
|
||||
"""Cache the (z, x, y) tiles ahead of reads."""
|
||||
self.source.prefetch([located for located in (self.locate(*tile) for tile in tiles) if located])
|
||||
|
||||
def close(self):
|
||||
self.directory.cache_clear()
|
||||
self.source.close()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Deterministic CPU rasterization of Protomaps roads and a supplied route.
|
||||
|
||||
Styled after the training map renderer: 1 m per pixel, the ego three quarters
|
||||
down the frame, pure white roads whose casings are part of the stroke, bridges cutting over the roads beneath
|
||||
them, a grey route and a white ego arrow.
|
||||
"""
|
||||
import math
|
||||
from functools import lru_cache
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.selfdrive.navigation.pmtiles import PMTiles
|
||||
from openpilot.selfdrive.navigation.route import cumulative_distance, nearest_segment
|
||||
from openpilot.selfdrive.navigation.vector_tile import Road, decode_roads
|
||||
|
||||
SIZE = 256
|
||||
ZOOM = 15 # tile zoom; the style zoom is derived from the 1 m per pixel scale instead
|
||||
FORWARD_OFFSET = 64.0 # the ego sits three quarters down the frame
|
||||
EGO = np.array([SIZE / 2, SIZE / 2 + FORWARD_OFFSET]) # pixel position of the ego
|
||||
EARTH_CIRCUMFERENCE = 40075016.68557849 # equatorial, for Web Mercator
|
||||
BACKGROUND = 0
|
||||
ROAD_INTENSITY = 255
|
||||
ROUTE_INTENSITY = 128
|
||||
ROUTE_WIDTH = 5.0 # pixels
|
||||
CUT_WIDTH = 3.0 # black separator under a bridge, added to the road width
|
||||
MAX_ROAD_DISTANCE = 100.0 # meters from the ego to the nearest road centerline for a valid map
|
||||
ARROW = np.array([(0, -9), (7, 7), (0, 3), (-7, 7)], dtype=np.float64) # ego marker, pixels around the ego
|
||||
ARROW_OUTLINE = 2.0 # pixels
|
||||
|
||||
|
||||
# The training style's road classes as (zoom, width, zoom, width) stops for the interior and for one side of the
|
||||
# casing, in pixels. Casings are white there too, so they only widen the stroke. Only each class's top interval is
|
||||
# kept: the style zoom at 1 m per pixel stays above 14 until 78 degrees of latitude, where the lower stops begin.
|
||||
MAJOR, MEDIUM, STREET, MINOR, TRACK = range(5)
|
||||
STOPS = np.array([[[5, 0.75, 18, 32], [10, 1, 18, 2]], # MAJOR: motorway, trunk, primary
|
||||
[[5, 0.1, 18, 26], [10, 0.75, 18, 2]], # MEDIUM: secondary, tertiary
|
||||
[[14, 2, 18, 18], [12, 0.75, 20, 2]], # STREET: residential, unclassified, living streets, major links
|
||||
[[14, 0.5, 18, 12], [12, 0.75, 20, 2]], # MINOR: service roads and minor links
|
||||
[[14, 1, 18, 12], [12, 0.75, 20, 2]]], dtype=np.float64) # TRACK
|
||||
|
||||
|
||||
def stroke_widths(zoom: float, styles: np.ndarray, levels: np.ndarray) -> np.ndarray:
|
||||
"""Full stroke width of each segment: the style's exponential (base 1.5) interpolation between its stops, flat
|
||||
beyond them, with the casing on both sides except for tunnels, which are drawn at their interior width."""
|
||||
z0, w0, z1, w1 = np.moveaxis(STOPS, -1, 0)
|
||||
z = np.clip(zoom, z0, z1)
|
||||
table = w0 + (w1 - w0) * (1.5 ** (z - z0) - 1) / (1.5 ** (z1 - z0) - 1) # (style, interior / casing)
|
||||
return table[styles, 0] + np.where(levels < 0, 0, 2 * table[styles, 1])
|
||||
|
||||
|
||||
def road_style(road: Road) -> int | None:
|
||||
"""The style for a road, or None when hidden. Protomaps kind_detail is the underlying OSM highway type."""
|
||||
detail = road.detail
|
||||
if detail in ('platform', 'pier', 'raceway'):
|
||||
return None
|
||||
if detail in ('motorway', 'trunk', 'primary') or (not detail and road.kind == 'highway'):
|
||||
return MAJOR
|
||||
if detail in ('secondary', 'tertiary') or (not detail and road.kind == 'major_road'):
|
||||
return MEDIUM
|
||||
if detail == 'track':
|
||||
return TRACK
|
||||
if detail in ('secondary_link', 'tertiary_link', 'service', 'driveway', 'parking_aisle', 'alley') or (
|
||||
road.kind == 'other' and detail != 'living_street'):
|
||||
return MINOR
|
||||
return STREET
|
||||
|
||||
|
||||
def road_level(road: Road) -> int:
|
||||
"""Draw order: tunnels, then ground roads, then bridges, each cutting over the levels beneath."""
|
||||
return 1 if road.bridge else -1 if road.tunnel else 0
|
||||
|
||||
|
||||
def mercator(coordinates: np.ndarray) -> np.ndarray:
|
||||
"""Longitude/latitude -> normalized Web Mercator, x east / y south."""
|
||||
longitude, latitude = coordinates.T
|
||||
y = (1.0 - np.arcsinh(np.tan(np.deg2rad(latitude))) / np.pi) / 2.0
|
||||
return np.column_stack(((longitude + 180.0) / 360.0, y))
|
||||
|
||||
|
||||
def project(points: np.ndarray, longitude: float, latitude: float, bearing: float) -> np.ndarray:
|
||||
"""Mercator points to pixels of a frame at a position, headed along a bearing in degrees clockwise from north."""
|
||||
origin = mercator(np.array([[longitude, latitude]]))[0]
|
||||
delta = points - origin
|
||||
delta[:, 0] = (delta[:, 0] + 0.5) % 1.0 - 0.5 # wrap across the antimeridian
|
||||
delta *= EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)) # meters, which are pixels
|
||||
c, s = math.cos(math.radians(bearing)), math.sin(math.radians(bearing))
|
||||
return EGO + delta @ np.array([[c, -s], [s, c]]) # x right, y down
|
||||
|
||||
|
||||
def draw_segments(image: np.ndarray, starts: np.ndarray, ends: np.ndarray, radii: np.ndarray | float, intensity: int,
|
||||
*, overlay: bool = False, caps: np.ndarray | None = None):
|
||||
"""Strokes with round joins and one pixel of analytic edge antialiasing.
|
||||
|
||||
caps is (segments, 2): whether each segment's start/end gets a round cap; without it every end is round.
|
||||
Max blending (the default) makes draw order irrelevant; overlay replaces the pixels beneath.
|
||||
"""
|
||||
radii = np.broadcast_to(np.asarray(radii, dtype=np.float64), (len(starts),))
|
||||
reach = radii[:, None] + 1
|
||||
low = np.maximum(np.floor(np.minimum(starts, ends) - reach), 0).astype(int)
|
||||
high = np.minimum(np.ceil(np.maximum(starts, ends) + reach), [image.shape[1] - 1, image.shape[0] - 1]).astype(int)
|
||||
# Cull offscreen segments in one pass, then loop over plain Python floats.
|
||||
visible = np.flatnonzero((high >= low).all(axis=1))
|
||||
round_caps = np.ones((len(starts), 2), dtype=bool) if caps is None else caps
|
||||
for i, (sx, sy), (ex, ey), (x0, y0), (x1, y1), radius in zip(visible.tolist(), starts[visible].tolist(), ends[visible].tolist(),
|
||||
low[visible].tolist(), high[visible].tolist(), radii[visible].tolist(),
|
||||
strict=True):
|
||||
dx, dy = ex - sx, ey - sy
|
||||
px = np.arange(x0, x1 + 1) + (0.5 - sx)
|
||||
py = (np.arange(y0, y1 + 1) + (0.5 - sy))[:, None]
|
||||
along = (px * dx + py * dy) / max(dx * dx + dy * dy, 1e-12)
|
||||
t = np.clip(along, 0, 1)
|
||||
alpha = np.clip(radius + 0.5 - np.hypot(px - t * dx, py - t * dy), 0, 1)
|
||||
if not round_caps[i, 0]:
|
||||
alpha = alpha * (along >= 0)
|
||||
if not round_caps[i, 1]:
|
||||
alpha = alpha * (along <= 1)
|
||||
view = image[y0:y1 + 1, x0:x1 + 1]
|
||||
if overlay:
|
||||
view[:] = np.rint(view * (1 - alpha) + intensity * alpha).astype(np.uint8)
|
||||
else:
|
||||
np.maximum(view, np.rint(alpha * intensity).astype(np.uint8), out=view)
|
||||
|
||||
|
||||
def draw_vehicle(image: np.ndarray):
|
||||
"""The training ego marker: a white arrow with a black outline, drawn over everything else."""
|
||||
arrow = ARROW + EGO
|
||||
(x0, y0), (x1, y1) = (np.floor(arrow.min(axis=0)) - 2).astype(int), (np.ceil(arrow.max(axis=0)) + 2).astype(int)
|
||||
px, py = np.meshgrid(np.arange(x0, x1) + 0.5, np.arange(y0, y1) + 0.5)
|
||||
inside = np.zeros(px.shape, dtype=bool)
|
||||
for a, b, c in ((arrow[0], arrow[1], arrow[2]), (arrow[0], arrow[2], arrow[3])): # the concave arrow as two triangles
|
||||
sides = [(q[0] - p[0]) * (py - p[1]) - (q[1] - p[1]) * (px - p[0]) for p, q in ((a, b), (b, c), (c, a))]
|
||||
inside |= ((sides[0] >= 0) & (sides[1] >= 0) & (sides[2] >= 0)) | ((sides[0] <= 0) & (sides[1] <= 0) & (sides[2] <= 0))
|
||||
image[y0:y1, x0:x1][inside] = 255
|
||||
draw_segments(image, arrow, np.roll(arrow, -1, axis=0), ARROW_OUTLINE / 2, 0, overlay=True)
|
||||
|
||||
|
||||
class MapRenderer:
|
||||
def __init__(self, archive: PMTiles):
|
||||
self.archive = archive
|
||||
self.zoom = min(max(ZOOM, archive.min_zoom), archive.max_zoom)
|
||||
self.roads = lru_cache(maxsize=128)(self._load_roads)
|
||||
|
||||
def _load_roads(self, x: int, y: int):
|
||||
"""A tile's visible road segments as flat arrays: mercator starts/ends, style index, level and polyline end flags."""
|
||||
tile = self.archive.get(self.zoom, x, y)
|
||||
if tile is None:
|
||||
return None
|
||||
starts, ends, styles, levels, caps = [np.empty((0, 2))], [np.empty((0, 2))], [np.empty(0, int)], [np.empty(0, int)], [np.empty((0, 2), bool)]
|
||||
for road in decode_roads(tile):
|
||||
style = road_style(road)
|
||||
if style is None:
|
||||
continue
|
||||
points = (road.points + np.array([x, y])) / (1 << self.zoom)
|
||||
n = len(points) - 1
|
||||
starts.append(points[:-1])
|
||||
ends.append(points[1:])
|
||||
styles.append(np.full(n, style))
|
||||
levels.append(np.full(n, road_level(road)))
|
||||
ends_of_line = np.zeros((n, 2), dtype=bool)
|
||||
ends_of_line[0, 0] = ends_of_line[-1, 1] = True
|
||||
caps.append(ends_of_line)
|
||||
return np.concatenate(starts), np.concatenate(ends), np.concatenate(styles), np.concatenate(levels), np.concatenate(caps)
|
||||
|
||||
def view_tiles(self, longitude: float, latitude: float) -> list[tuple[int, int]]:
|
||||
"""Tiles covering the view at a point for every heading."""
|
||||
origin = mercator(np.array([[longitude, latitude]]))[0]
|
||||
# A bounding circle includes all headings, forward offset and road stroke widths.
|
||||
radius = (SIZE / math.sqrt(2) + FORWARD_OFFSET + 16) / (EARTH_CIRCUMFERENCE * math.cos(math.radians(latitude)))
|
||||
count = 1 << self.zoom
|
||||
low = np.floor((origin - radius) * count).astype(int)
|
||||
high = np.floor((origin + radius) * count).astype(int)
|
||||
return [(x % count, y) for y in range(low[1], high[1] + 1) for x in range(low[0], high[0] + 1) if 0 <= y < count]
|
||||
|
||||
def route_tiles(self, route: np.ndarray) -> set[tuple[int, int]]:
|
||||
"""Every tile the view can show while the pose is snapped onto the route, sampled every 100 m."""
|
||||
distance = cumulative_distance(route)
|
||||
samples = np.column_stack([np.interp(np.arange(0, distance[-1], 100), distance, route[:, axis]) for axis in (0, 1)])
|
||||
return {tile for lon, lat in np.vstack((samples, route)).tolist() for tile in self.view_tiles(lon, lat)}
|
||||
|
||||
def prefetch(self, route: np.ndarray):
|
||||
self.archive.prefetch((self.zoom, x, y) for x, y in self.route_tiles(route))
|
||||
|
||||
def render(self, longitude: float, latitude: float, bearing: float, route: np.ndarray) -> tuple[np.ndarray, bool]:
|
||||
"""The map image at a position and bearing, and whether every tile was present with a road near the ego."""
|
||||
image = np.full((SIZE, SIZE), BACKGROUND, dtype=np.uint8)
|
||||
loaded = [self.roads(x, y) for x, y in self.view_tiles(longitude, latitude)]
|
||||
tiles = [tile for tile in loaded if tile is not None]
|
||||
# The training renderer's latitude-dependent style zoom: the web mercator zoom with one meter per pixel
|
||||
# at this latitude, under the 512-pixel tile convention. Tile zoom and style zoom are independent.
|
||||
style_zoom = math.log2(math.cos(math.radians(latitude)) * EARTH_CIRCUMFERENCE / 256) - 1
|
||||
road_distance = math.inf
|
||||
if tiles:
|
||||
starts, ends, styles, levels, caps = (np.concatenate(column) for column in zip(*tiles, strict=True))
|
||||
starts, ends = project(starts, longitude, latitude, bearing), project(ends, longitude, latitude, bearing)
|
||||
widths = stroke_widths(style_zoom, styles, levels)
|
||||
if len(starts):
|
||||
road_distance = nearest_segment(starts, ends, EGO)[2]
|
||||
for level in np.unique(levels):
|
||||
chosen = levels == level
|
||||
if level > levels.min():
|
||||
# Cut the roads beneath, open at way endpoints so a bridge does not end in a black oval.
|
||||
draw_segments(image, starts[chosen], ends[chosen], (widths[chosen] + CUT_WIDTH) / 2, BACKGROUND,
|
||||
overlay=True, caps=~caps[chosen])
|
||||
draw_segments(image, starts[chosen], ends[chosen], widths[chosen] / 2, ROAD_INTENSITY)
|
||||
pixels = project(mercator(route), longitude, latitude, bearing)
|
||||
draw_segments(image, pixels[:-1], pixels[1:], ROUTE_WIDTH / 2, ROUTE_INTENSITY, overlay=True)
|
||||
draw_vehicle(image)
|
||||
return image, len(tiles) == len(loaded) and road_distance <= MAX_ROAD_DISTANCE
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Planar geometry for routes, and route-relative map pose and progress tracking."""
|
||||
import numpy as np
|
||||
|
||||
METERS_PER_DEGREE = 2 * np.pi * 6371008.8 / 360 # mean Earth radius, as Mapbox measures distances
|
||||
|
||||
# Training renders the map on the route rather than at the measured position:
|
||||
# the ego is projected onto the route, the frame is centered there and headed along the route's tangent.
|
||||
PROJECTION_WINDOW = (-30.0, 100.0) # meters around the previous progress to search; a loop cannot capture the ego
|
||||
HEADING_HALF_WINDOW = 50.0 # meters; the heading is the chord of the route this far behind and ahead
|
||||
|
||||
|
||||
def local_meters(points, origin) -> np.ndarray:
|
||||
"""Equirectangular east/north meters of (longitude, latitude) points around an origin."""
|
||||
origin = np.asarray(origin, dtype=np.float64)
|
||||
scale = np.array([METERS_PER_DEGREE * np.cos(np.radians(origin[1])), METERS_PER_DEGREE])
|
||||
return (np.asarray(points, dtype=np.float64) - origin) * scale
|
||||
|
||||
|
||||
def nearest_segment(starts: np.ndarray, ends: np.ndarray, point) -> tuple[int, float, float]:
|
||||
"""(index, fraction along it, distance) of the closest spot on the segments, in the inputs' units."""
|
||||
ab = ends - starts
|
||||
t = np.clip(((point - starts) * ab).sum(axis=1) / np.maximum((ab * ab).sum(axis=1), 1e-12), 0, 1)
|
||||
d = np.linalg.norm(starts + t[:, None] * ab - point, axis=1)
|
||||
i = int(d.argmin())
|
||||
return i, float(t[i]), float(d[i])
|
||||
|
||||
|
||||
def cumulative_distance(route: np.ndarray) -> np.ndarray:
|
||||
"""Meters along [(longitude, latitude), ...]."""
|
||||
(lon1, lat1), (lon2, lat2) = route[:-1].T, route[1:].T
|
||||
steps = np.hypot((lon2 - lon1) * np.cos(np.radians((lat1 + lat2) / 2)), lat2 - lat1) * METERS_PER_DEGREE
|
||||
return np.r_[0.0, np.cumsum(steps)]
|
||||
|
||||
|
||||
def along_path(path: np.ndarray, point) -> tuple[float, float]:
|
||||
"""(meters along a (longitude, latitude) path to a point, and the point's distance from the path):
|
||||
the path up to the start of the nearest segment, then straight to the point."""
|
||||
local, p = local_meters(path, path[0]), local_meters(point, path[0])
|
||||
segment, _, lateral = nearest_segment(local[:-1], local[1:], p)
|
||||
return float(cumulative_distance(path)[segment] + np.linalg.norm(local_meters(point, path[segment]))), lateral
|
||||
|
||||
|
||||
class RouteTracker:
|
||||
"""Progress of the ego along a route, in meters, and the map pose at that progress."""
|
||||
|
||||
def __init__(self, route: np.ndarray):
|
||||
self.route = np.asarray(route, dtype=np.float64).reshape((-1, 2))
|
||||
self.path = local_meters(self.route, self.route[0])
|
||||
self.distance = cumulative_distance(self.route)
|
||||
self.progress = -np.inf # the first fix may land anywhere on the route
|
||||
|
||||
def project(self, longitude: float, latitude: float) -> float:
|
||||
"""Advance to the nearest spot on the route within the window ahead of the previous progress."""
|
||||
near = np.flatnonzero((self.distance[1:] >= self.progress + PROJECTION_WINDOW[0]) & (self.distance[:-1] <= self.progress + PROJECTION_WINDOW[1]))
|
||||
if not len(near): # only before the first fix, when the progress is still -inf
|
||||
near = np.arange(len(self.path) - 1)
|
||||
point = local_meters([longitude, latitude], self.route[0])
|
||||
segment, along, _ = nearest_segment(self.path[near], self.path[near + 1], point)
|
||||
segment = near[segment]
|
||||
self.progress = max(self.progress, self.distance[segment] + along * (self.distance[segment + 1] - self.distance[segment]))
|
||||
return self.progress
|
||||
|
||||
def pose(self, progress: float) -> tuple[float, float, float]:
|
||||
"""(longitude, latitude, bearing) on the route at a progress, headed along the +-50m chord."""
|
||||
ends = np.clip([progress - HEADING_HALF_WINDOW, progress, progress + HEADING_HALF_WINDOW], 0, self.distance[-1])
|
||||
(lon0, lon, lon1), (lat0, lat, lat1) = (np.interp(ends, self.distance, self.route[:, axis]) for axis in (0, 1))
|
||||
bearing = np.degrees(np.arctan2((lon1 - lon0) * np.cos(np.radians(lat0)), lat1 - lat0)) % 360
|
||||
return float(lon), float(lat), float(bearing)
|
||||
|
||||
def snap(self, longitude: float, latitude: float) -> tuple[float, float, float]:
|
||||
return self.pose(self.project(longitude, latitude))
|
||||
@@ -0,0 +1,485 @@
|
||||
import gzip
|
||||
import re
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import closing, contextmanager
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.common.prefix import OpenpilotPrefix
|
||||
from openpilot.selfdrive.navigation.mapsd import MapsD
|
||||
from openpilot.selfdrive.navigation.navd import REROUTE_COUNTER_MIN, ROUTE_RESEND_PERIOD, RouteEngine, set_banner
|
||||
from openpilot.selfdrive.navigation.pmtiles import PMTiles, decode_directory, tile_id
|
||||
from openpilot.selfdrive.navigation.renderer import MAJOR, MEDIUM, MINOR, SIZE, STREET, TRACK, MapRenderer, draw_segments, mercator, project, stroke_widths
|
||||
from openpilot.selfdrive.navigation.route import RouteTracker
|
||||
from openpilot.selfdrive.navigation.tiles import RemoteTiles
|
||||
from openpilot.selfdrive.navigation.vector_tile import decode_lines, decode_roads
|
||||
|
||||
METER = 1 / 111319.49079327358 # degrees of latitude
|
||||
|
||||
|
||||
def draw_line(image, points, intensity, width, **kwargs):
|
||||
draw_segments(image, points[:-1], points[1:], width / 2, intensity, **kwargs)
|
||||
|
||||
|
||||
def varint(value):
|
||||
output = bytearray()
|
||||
while value > 127:
|
||||
output.append((value & 127) | 128)
|
||||
value >>= 7
|
||||
output.append(value)
|
||||
return bytes(output)
|
||||
|
||||
|
||||
def field(number, value):
|
||||
if isinstance(value, bytes):
|
||||
return varint(number << 3 | 2) + varint(len(value)) + value
|
||||
return varint(number << 3) + varint(value)
|
||||
|
||||
|
||||
def road_tile(crossing=None):
|
||||
# One vertical line, from (2048, 0) to (2048, 4096), and one non-road layer. With crossing='bridge' or 'tunnel',
|
||||
# a horizontal line from (1024, 2048) to (3072, 2048) at that level.
|
||||
geometry = b''.join(varint(v) for v in [9, 4096, 0, 10, 0, 8192])
|
||||
feature = field(2, b'\x00\x00') + field(3, 2) + field(4, geometry)
|
||||
roads = field(15, 2) + field(1, b'roads') + field(2, feature)
|
||||
if crossing is not None:
|
||||
geometry = b''.join(varint(v) for v in [9, 2048, 4096, 10, 4096, 0])
|
||||
roads += field(2, field(2, b'\x00\x00\x01\x01') + field(3, 2) + field(4, geometry))
|
||||
roads += field(3, b'kind') + field(3, f'is_{crossing}'.encode()) + field(4, field(1, b'minor_road')) + field(4, field(7, 1)) + field(5, 4096)
|
||||
return field(3, field(1, b'buildings')) + field(3, roads)
|
||||
|
||||
|
||||
def make_archive(payload=None, leaf=False, run=1, max_zoom=0):
|
||||
"""A PMTiles archive with one zoom-zero tile, in the root directory or in a leaf directory."""
|
||||
tile = gzip.compress(road_tile() if payload is None else payload)
|
||||
directory = gzip.compress(varint(1) + varint(0) + varint(run) + varint(len(tile)) + varint(1))
|
||||
child = directory if leaf else b''
|
||||
root = gzip.compress(varint(1) + varint(0) + varint(0) + varint(len(child)) + varint(1)) if leaf else directory
|
||||
metadata = gzip.compress(b'{"vector_layers":[{"id":"roads"}]}')
|
||||
header = bytearray(127)
|
||||
header[:8] = b'PMTiles\x03'
|
||||
offsets = [127, 127 + len(root), 127 + len(root) + len(metadata), 127 + len(root) + len(metadata) + len(child)]
|
||||
struct.pack_into('<11Q', header, 8, offsets[0], len(root), offsets[1], len(metadata), offsets[2], len(child), offsets[3], len(tile), 1, 1, 1)
|
||||
header[96:102] = bytes([1, 2, 2, 1, 0, max_zoom])
|
||||
return bytes(header + root + metadata + child + tile)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def local_archive(data):
|
||||
"""A PMTiles reader over in-memory bytes, bypassing the tile server."""
|
||||
source = MagicMock(size=len(data))
|
||||
source.read.side_effect = lambda offset, length: data[offset:offset + length]
|
||||
with closing(PMTiles(source)) as archive:
|
||||
yield archive
|
||||
|
||||
|
||||
def mapbox_response(steps):
|
||||
response = MagicMock(status_code=200)
|
||||
response.json.return_value = {'routes': [{'legs': [{'steps': steps}]}]}
|
||||
return response
|
||||
|
||||
|
||||
def route_engine(sm, pm, values):
|
||||
params = MagicMock()
|
||||
params.get.side_effect = values.get
|
||||
params.remove.side_effect = lambda key: values.pop(key, None)
|
||||
with patch('openpilot.selfdrive.navigation.navd.Api') as api:
|
||||
api.return_value.get_token.return_value = 'test-token'
|
||||
return RouteEngine(sm, pm, params=params)
|
||||
|
||||
|
||||
class RangeHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.server.requests.append(dict(self.headers))
|
||||
if self.headers.get('If-Match', self.server.etag) != self.server.etag:
|
||||
self.send_error(412)
|
||||
return
|
||||
if self.server.ignore_range:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
return
|
||||
offset, end = map(int, re.fullmatch(r'bytes=(\d+)-(\d+)', self.headers['Range']).groups())
|
||||
data = self.server.archive[offset:end + 1]
|
||||
self.send_response(206)
|
||||
self.send_header('Content-Range', f'bytes {offset}-{end}/{len(self.server.archive)}')
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
self.send_header('ETag', self.server.etag)
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None: # noqa: A002 # stdlib override
|
||||
pass
|
||||
|
||||
|
||||
class TileServerCase(unittest.TestCase):
|
||||
"""Serves a leaf-directory archive over HTTP range requests, with the comma API and tile cache stubbed."""
|
||||
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.cache = Path(self.directory.name) / 'navigation' / 'tiles'
|
||||
self.server = ThreadingHTTPServer(('127.0.0.1', 0), RangeHandler)
|
||||
self.server.archive = make_archive(leaf=True)
|
||||
self.server.etag = '"test-version"'
|
||||
self.server.ignore_range = False
|
||||
self.server.requests = []
|
||||
threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True).start()
|
||||
self.addCleanup(self.server.server_close)
|
||||
self.addCleanup(self.server.shutdown)
|
||||
self.api = MagicMock(dongle_id='test-dongle')
|
||||
self.api.get_token.return_value = 'device-token'
|
||||
self.url = f'http://127.0.0.1:{self.server.server_port}/planet.pmtiles?sig=secret'
|
||||
self.api.get.return_value.json.return_value = {'url': self.url, 'expires': time.time() + 86400} # noqa: TID251
|
||||
for name, value in [('Api', MagicMock(return_value=self.api)), ('Params', MagicMock()), ('PC', True)]:
|
||||
self.enterContext(patch(f'openpilot.selfdrive.navigation.tiles.{name}', value))
|
||||
self.enterContext(patch('openpilot.selfdrive.navigation.tiles.Paths.comma_home', return_value=self.directory.name))
|
||||
|
||||
|
||||
class TestEndToEnd(TileServerCase):
|
||||
def test_navd_to_map_frames(self):
|
||||
"""navd routes a destination over messaging; mapsd fetches tiles over HTTP and renders map frames."""
|
||||
destination = {'latitude': 0.01, 'longitude': 0}
|
||||
self.enterContext(patch('requests.get', return_value=mapbox_response([
|
||||
{'geometry': {'coordinates': [[0, -0.01], [0, 0.01]]}, 'distance': 2224, 'duration': 100, 'bannerInstructions': []}])))
|
||||
with OpenpilotPrefix():
|
||||
pm = messaging.PubMaster(['navInstruction', 'navRoute', 'gpsLocationExternal'])
|
||||
sm = messaging.SubMaster(['gpsLocation', 'gpsLocationExternal', 'navRoute'])
|
||||
values = {'NavDestination': dict(destination)}
|
||||
navd = route_engine(messaging.SubMaster(['gpsLocation', 'gpsLocationExternal']), pm, values)
|
||||
mapsd = MapsD()
|
||||
self.addCleanup(mapsd.close)
|
||||
|
||||
def frame(fix=True):
|
||||
gps = messaging.new_message('gpsLocationExternal', valid=True)
|
||||
gps.gpsLocationExternal.hasFix = fix
|
||||
pm.send('gpsLocationExternal', gps)
|
||||
navd.update()
|
||||
sm.update(0)
|
||||
return mapsd.update(sm)
|
||||
|
||||
image, timestamp = frame()
|
||||
# The route is centered over the white road, with the ego arrow three quarters down.
|
||||
self.assertEqual((image[128, 128], image[128, 131], image[188, 128], image[195, 128]), (128, 255, 255, 0))
|
||||
self.assertEqual(timestamp, sm.logMonoTime['gpsLocationExternal'])
|
||||
fetched = len(self.server.requests)
|
||||
tracker = mapsd.tracker
|
||||
navd.send_route() # the same route again, as a restarted mapsd would get it
|
||||
frame()
|
||||
self.assertIs(mapsd.tracker, tracker) # progress along it is kept
|
||||
values.pop('NavDestination')
|
||||
self.assertFalse(frame()[0].any()) # black without a route
|
||||
values['NavDestination'] = dict(destination)
|
||||
self.assertEqual(frame()[0][128, 128], 128)
|
||||
self.assertEqual(len(self.server.requests), fetched) # the second route is served from the cache
|
||||
image, timestamp = frame(fix=False)
|
||||
self.assertFalse(image.any() or timestamp) # black, not stale, without a fix
|
||||
self.api.get.assert_called_once()
|
||||
self.assertTrue(all('Range' in headers for headers in self.server.requests))
|
||||
self.assertTrue(all(headers['If-Match'] == self.server.etag for headers in self.server.requests[1:]))
|
||||
self.assertTrue(all(b'secret' not in p.read_bytes() for p in self.cache.iterdir()))
|
||||
# The archive is replaced on the server and the cache evicted: the next fetch fails, the map goes black,
|
||||
# and after a delay a fresh reader starts over on the new version.
|
||||
self.server.etag = '"new-version"'
|
||||
for cached in self.cache.iterdir():
|
||||
cached.unlink()
|
||||
values.pop('NavDestination')
|
||||
frame()
|
||||
values['NavDestination'] = dict(destination)
|
||||
image, timestamp = frame()
|
||||
self.assertFalse(image.any() or timestamp)
|
||||
fetched = len(self.server.requests)
|
||||
self.assertFalse(frame()[1]) # not retried yet
|
||||
self.assertEqual(len(self.server.requests), fetched)
|
||||
mapsd.retry_at = 0
|
||||
self.assertEqual(frame()[0][128, 128], 128)
|
||||
self.assertEqual((self.cache / 'version').read_text().split()[0], '"new-version"')
|
||||
|
||||
|
||||
class TestRemoteTiles(TileServerCase):
|
||||
def test_shared_cache_and_refresh(self):
|
||||
with closing(PMTiles(RemoteTiles())) as archive:
|
||||
archive.source.expires = 0 # the signed URL is refreshed before the next fetch
|
||||
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
||||
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
||||
self.assertEqual(self.api.get.call_count, 2)
|
||||
count = len(self.server.requests)
|
||||
with closing(PMTiles(RemoteTiles())) as archive:
|
||||
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
||||
# A second reader revalidates only the header; directories and tiles are shared on disk.
|
||||
self.assertEqual(len(self.server.requests), count + 1)
|
||||
with patch('openpilot.selfdrive.navigation.tiles.CACHE_LIMIT', 1), closing(RemoteTiles()) as source:
|
||||
source.read(1, 2) # an uncached range is downloaded, then the cache is trimmed to the limit
|
||||
self.assertEqual([p.name for p in self.cache.iterdir()], ['version'])
|
||||
|
||||
def test_prefetch_merges_nearby_ranges(self):
|
||||
with patch('openpilot.selfdrive.navigation.tiles.MERGE_GAP', 16), closing(RemoteTiles()) as source:
|
||||
count = len(self.server.requests)
|
||||
source.prefetch([(20, 10), (0, 10), (60, 10), (20, 10)])
|
||||
self.assertEqual(len(self.server.requests), count + 2) # 0-29 in one request, 60-69 in another
|
||||
self.assertEqual(self.server.requests[-2]['Range'], 'bytes=0-29')
|
||||
for offset in (0, 20, 60):
|
||||
self.assertEqual(source.read(offset, 10), self.server.archive[offset:offset + 10])
|
||||
self.assertEqual(len(self.server.requests), count + 2)
|
||||
|
||||
def test_failures(self):
|
||||
with closing(PMTiles(RemoteTiles())) as archive:
|
||||
self.server.etag = '"new-version"'
|
||||
with self.assertRaisesRegex(OSError, '412'): # the archive changed under an open reader
|
||||
archive.get(0, 0, 0)
|
||||
with closing(PMTiles(RemoteTiles())) as archive: # a new reader starts over on the new version
|
||||
self.assertEqual(archive.get(0, 0, 0), road_tile())
|
||||
self.server.ignore_range = True
|
||||
with self.assertRaisesRegex(OSError, '200'):
|
||||
RemoteTiles()
|
||||
self.api.get.return_value.raise_for_status.side_effect = requests.HTTPError(self.url)
|
||||
with self.assertRaisesRegex(OSError, '^Unable to fetch map tiles$'): # the signed URL stays out of the error
|
||||
RemoteTiles()
|
||||
|
||||
|
||||
class TestRouteEngine(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.pm = MagicMock()
|
||||
self.values = {'NavDestination': {'latitude': 0, 'longitude': 0.002}, 'LanguageSetting': 'main_en'}
|
||||
self.sm = MagicMock()
|
||||
self.sm.valid = dict.fromkeys(('gpsLocation', 'gpsLocationExternal'), True)
|
||||
self.sm.logMonoTime = dict.fromkeys(self.sm.valid, time.monotonic_ns())
|
||||
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic())
|
||||
self.sm.__getitem__.return_value = SimpleNamespace(hasFix=True, latitude=0, longitude=0, bearingDeg=90)
|
||||
self.engine = route_engine(self.sm, self.pm, self.values)
|
||||
self.engine.last_position, self.engine.last_bearing = (0, 0), 90
|
||||
distance = 111.195 # Mapbox's length of 0.001 degrees at the equator
|
||||
self.response = mapbox_response([
|
||||
{'geometry': {'coordinates': [[0, 0], [0.0005, 0], [0.001, 0]]}, 'distance': distance, 'duration': 10,
|
||||
'bannerInstructions': [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}}]},
|
||||
{'geometry': {'coordinates': [[0.001, 0], [0.002, 0]]}, 'distance': distance, 'duration': 10, 'bannerInstructions': []},
|
||||
])
|
||||
|
||||
def last_message(self, service):
|
||||
return next(call.args[1] for call in reversed(self.pm.send.call_args_list) if call.args[0] == service)
|
||||
|
||||
def load_route(self):
|
||||
with patch('requests.get', return_value=self.response) as get:
|
||||
self.engine.calculate_route((0.002, 0))
|
||||
self.engine.send_route()
|
||||
return get
|
||||
|
||||
def test_request_and_instruction(self):
|
||||
get = self.load_route()
|
||||
self.assertEqual(get.call_args.args[0], 'https://api.commadotai.com/v1/nav/directions/v5/mapbox/driving-traffic/0,0;0.002,0')
|
||||
options = get.call_args.kwargs['params']
|
||||
self.assertEqual((options['access_token'], options['bearings'], options['language']), ('test-token', '90,90;', 'en'))
|
||||
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 5)
|
||||
self.engine.send_instruction()
|
||||
message = self.last_message('navInstruction')
|
||||
self.assertTrue(message.valid)
|
||||
instruction = message.navInstruction.to_dict()
|
||||
self.assertEqual((instruction['maneuverPrimaryText'], instruction['maneuverModifier'], instruction['timeRemaining']), ('Turn right', 'right', 20))
|
||||
self.engine.last_position = (0.0007, 0) # on the step's second segment
|
||||
self.engine.send_instruction()
|
||||
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.timeRemaining, 13, delta=0.05)
|
||||
self.engine.last_position = (0.001, 0) # at the maneuver
|
||||
self.engine.send_instruction()
|
||||
self.assertAlmostEqual(self.last_message('navInstruction').navInstruction.maneuverDistance, 0, delta=0.01)
|
||||
|
||||
def test_banner_selection(self):
|
||||
banners = [{'distanceAlongGeometry': 100, 'primary': {'text': 'Turn right', 'type': 'turn', 'modifier': 'right'}},
|
||||
{'distanceAlongGeometry': 50, 'primary': {'text': 'Turn right'}, 'secondary': {'text': 'onto Main St'}}]
|
||||
# The last banner still ahead of the ego, else the first: 120 m, 70 m and 30 m from the maneuver.
|
||||
for distance, expected in [(120, ('Turn right', '', False)), (70, ('Turn right', '', True)), (30, ('Turn right', 'onto Main St', True))]:
|
||||
instruction = messaging.new_message('navInstruction').navInstruction
|
||||
set_banner(instruction, banners, distance)
|
||||
self.assertEqual((instruction.maneuverPrimaryText, instruction.maneuverSecondaryText, instruction.showFull), expected)
|
||||
|
||||
def test_route_sent_on_change_and_periodically(self):
|
||||
sent = lambda: sum(call.args[0] == 'navRoute' for call in self.pm.send.call_args_list) # noqa: E731
|
||||
self.sm.frame = 1
|
||||
with patch('requests.get', return_value=self.response):
|
||||
self.engine.update() # the route to a new destination is sent once
|
||||
self.engine.update()
|
||||
self.assertEqual(sent(), 1)
|
||||
self.sm.frame = ROUTE_RESEND_PERIOD # and again periodically, for a mapsd that started after it
|
||||
self.engine.update()
|
||||
self.assertEqual(sent(), 2)
|
||||
|
||||
def test_maneuvers_and_rerouting(self):
|
||||
self.load_route()
|
||||
self.engine.last_position = (0, 0.001) # 111 m off route: reroute after a few consecutive misses
|
||||
for _ in range(REROUTE_COUNTER_MIN):
|
||||
self.assertFalse(self.engine.should_recompute())
|
||||
self.assertTrue(self.engine.should_recompute())
|
||||
with patch('requests.get') as get:
|
||||
self.engine.recompute_route(gps_ok=False) # but not while the GPS drifts in a tunnel
|
||||
get.assert_not_called()
|
||||
with patch('requests.get', return_value=self.response) as get:
|
||||
self.engine.recompute_route(gps_ok=True)
|
||||
self.engine.reroute_counter = REROUTE_COUNTER_MIN # still off route, but an immediate second recompute is held back
|
||||
self.engine.recompute_route(gps_ok=True)
|
||||
self.assertEqual(get.call_count, 1)
|
||||
self.engine.last_position = (0.0012, 0) # past the first maneuver
|
||||
self.engine.send_instruction()
|
||||
self.assertEqual(self.engine.step_idx, 1)
|
||||
self.engine.last_position = (0.00215, 0) # 17 m past the destination: arrived, but the route is kept nearby
|
||||
self.engine.send_instruction()
|
||||
self.assertIsNotNone(self.engine.route)
|
||||
self.engine.last_position = (0.0024, 0) # driving away from the destination
|
||||
self.engine.send_instruction()
|
||||
self.assertIsNone(self.engine.route)
|
||||
self.assertNotIn('NavDestination', self.values)
|
||||
|
||||
def test_failed_request_clears_route(self):
|
||||
self.load_route()
|
||||
self.response.raise_for_status.side_effect = requests.HTTPError('test failure')
|
||||
self.load_route()
|
||||
self.assertIsNone(self.engine.route)
|
||||
self.assertEqual(len(self.last_message('navRoute').navRoute.coordinates), 0)
|
||||
self.engine.send_instruction()
|
||||
self.assertFalse(self.last_message('navInstruction').valid)
|
||||
|
||||
def test_gps_source(self):
|
||||
fixes = {'gpsLocation': SimpleNamespace(hasFix=True, latitude=1, longitude=1, bearingDeg=180),
|
||||
'gpsLocationExternal': SimpleNamespace(hasFix=True, latitude=2, longitude=2, bearingDeg=0)}
|
||||
self.sm.__getitem__.side_effect = fixes.get
|
||||
self.sm.logMonoTime = {'gpsLocation': 2, 'gpsLocationExternal': 1} # replayed logs are judged by receipt time
|
||||
with patch('requests.get', return_value=self.response):
|
||||
self.engine.update()
|
||||
self.assertEqual((self.engine.last_position, self.engine.last_bearing), ((1, 1), 180)) # the newest fix wins
|
||||
self.sm.valid['gpsLocation'] = False
|
||||
self.engine.update()
|
||||
self.assertEqual(self.engine.last_position, (2, 2)) # unless it is invalid
|
||||
fixes['gpsLocationExternal'] = SimpleNamespace(hasFix=True, latitude=3, longitude=3, bearingDeg=0)
|
||||
self.sm.recv_time = dict.fromkeys(self.sm.valid, time.monotonic() - 3) # or stale
|
||||
self.engine.update()
|
||||
self.assertEqual(self.engine.last_position, (2, 2))
|
||||
|
||||
|
||||
class TestTiles(unittest.TestCase):
|
||||
def test_pmtiles_format(self):
|
||||
self.assertEqual([tile_id(1, x, y) for x, y in [(0, 0), (0, 1), (1, 1), (1, 0)]], [1, 2, 3, 4])
|
||||
self.assertEqual(sorted(tile_id(3, x, y) for x in range(8) for y in range(8)), list(range(21, 85)))
|
||||
# IDs 5, 10; runs 3, 1; lengths 4, 6; the second offset follows the first blob.
|
||||
data = bytes([2, 5, 5, 3, 1, 4, 6, 1, 0])
|
||||
self.assertEqual(decode_directory(data), ([5, 10], [(3, 4, 0), (1, 6, 4)]))
|
||||
for invalid in (b'\x80', b'\x01\x00\x01\x01\x00', data + b'\x00'):
|
||||
with self.assertRaises(ValueError):
|
||||
decode_directory(invalid)
|
||||
with local_archive(make_archive(run=3, max_zoom=1)) as archive: # the zoom-zero tile is also addressed by IDs 1 and 2
|
||||
self.assertEqual(archive.get(1, 0, 1), road_tile())
|
||||
self.assertIsNone(archive.get(1, 1, 1))
|
||||
with self.assertRaises(ValueError), local_archive(make_archive()[:-1]):
|
||||
pass
|
||||
|
||||
def test_vector_tiles(self):
|
||||
roads = decode_roads(road_tile())
|
||||
self.assertEqual((len(roads), roads[0].kind), (1, 'minor_road'))
|
||||
np.testing.assert_array_equal(roads[0].points, [[0.5, 0], [0.5, 1]])
|
||||
lines = decode_lines([9, 20, 20, 10, 10, 0, 9, 9, 9, 10, 0, 10])
|
||||
np.testing.assert_array_equal(lines[0], [[10, 10], [15, 10]])
|
||||
np.testing.assert_array_equal(lines[1], [[10, 5], [10, 10]])
|
||||
for data in (b'\x1a\xff', b'\x00', b'\x1a\x02\x0a\x04'):
|
||||
with self.assertRaises(ValueError):
|
||||
decode_roads(data)
|
||||
for commands in ([10, 1, 1], [9, 0], [9, 0, 0, 15]):
|
||||
with self.assertRaises(ValueError):
|
||||
decode_lines(commands)
|
||||
|
||||
|
||||
class TestRendering(unittest.TestCase):
|
||||
def test_projection(self):
|
||||
# At the equator, 64m ahead is the image center and the car is 64px below it, at 1m per pixel.
|
||||
points = mercator(np.array([[0, 0], [0, 64 * METER], [64 * METER, 0]]))
|
||||
np.testing.assert_allclose(project(points, 0, 0, 0), [[128, 192], [128, 128], [192, 192]], atol=1e-5)
|
||||
np.testing.assert_allclose(project(points, 0, 0, 90), [[128, 192], [64, 192], [128, 128]], atol=1e-5)
|
||||
pixels = project(mercator(np.array([[-179.999, 0]])), 179.999, 0, 90) # across the antimeridian
|
||||
self.assertAlmostEqual(pixels[0, 1], 192 - 222.6389816, places=5)
|
||||
|
||||
def test_strokes(self):
|
||||
image = np.zeros((SIZE, SIZE), dtype=np.uint8)
|
||||
draw_line(image, np.array([[-1000, 128], [1000, 128]]), 255, 8)
|
||||
self.assertTrue(np.all(image[128] == 255))
|
||||
draw_line(image, np.array([[128, 0], [128, 256]]), 128, 5, overlay=True) # the grey route replaces the white road
|
||||
self.assertEqual((image[128, 128], image[128, 131]), (128, 255))
|
||||
before = image.copy()
|
||||
draw_line(image, np.array([[-1000, -1000], [-500, -500]]), 255, 5)
|
||||
np.testing.assert_array_equal(image, before)
|
||||
draw_line(image, np.array([[40, 40], [40, 40]]), 255, 5) # a zero-length segment is still a dot
|
||||
self.assertEqual(image[40, 40], 255)
|
||||
|
||||
def test_crossings(self):
|
||||
route = np.array([[0.01, 0], [0.01, 0.01]]) # off to the side, out of the way of the probes
|
||||
images = {}
|
||||
for crossing in ('bridge', 'tunnel'):
|
||||
with local_archive(make_archive(road_tile(crossing))) as archive:
|
||||
images[crossing] = MapRenderer(archive).render(0, -40 * METER, 0, route)[0] # the crossing 40m ahead
|
||||
# The horizontal road crosses the vertical one 40px above the ego, at row 152. A bridge severs the road beneath
|
||||
# just outside its own stroke; a tunnel passes underneath untouched. The cut has butt caps, so the
|
||||
# bridge's own end is not wrapped in black.
|
||||
self.assertEqual((images['bridge'][146, 128], images['bridge'][152, 128], images['bridge'][152, 200], images['bridge'][140, 128]), (0, 255, 255, 255))
|
||||
self.assertEqual((images['tunnel'][146, 128], images['tunnel'][152, 200]), (255, 255))
|
||||
# The ground road cuts over the tunnel beneath it, and the tunnel is drawn without its casing.
|
||||
self.assertEqual((images['tunnel'][152, 133], images['tunnel'][147, 200]), (0, 0))
|
||||
|
||||
def test_road_widths(self):
|
||||
# Pinned at the style zoom of 1 m per pixel: 16.26 at the equator and 15.26 at 60 degrees. Tunnels have no casing.
|
||||
styles, levels = np.array([MAJOR, MEDIUM, STREET, MINOR, TRACK, STREET]), np.array([0, 0, 0, 0, 0, -1])
|
||||
np.testing.assert_allclose(stroke_widths(16.256, styles, levels), [19.02, 15.48, 9.86, 6.70, 7.02, 7.89], atol=0.01)
|
||||
np.testing.assert_allclose(stroke_widths(15.256, styles, levels), [13.52, 10.78, 6.39, 4.16, 4.58, 4.62], atol=0.01)
|
||||
|
||||
def test_route_tiles(self):
|
||||
route = np.array([[0, -0.01], [0, 0.01]]) # 2.2 km north along the prime meridian
|
||||
with local_archive(make_archive(max_zoom=15)) as archive:
|
||||
renderer = MapRenderer(archive)
|
||||
tiles = renderer.route_tiles(route)
|
||||
# Both columns beside the meridian, and the rows within the view radius of the route's ends.
|
||||
self.assertEqual({x for x, _ in tiles}, {16383, 16384})
|
||||
self.assertEqual({y for _, y in tiles}, set(range(16382, 16386)))
|
||||
|
||||
def test_validity_and_display(self):
|
||||
route = np.array([[0, -0.01], [0, 0.01]])
|
||||
with local_archive(make_archive()) as archive:
|
||||
renderer = MapRenderer(archive)
|
||||
self.assertTrue(renderer.render(0, 0, 0, route)[1])
|
||||
self.assertFalse(renderer.render(0.01, 0, 0, route)[1]) # no road near the ego
|
||||
with patch.object(archive, 'get', return_value=None): # a missing tile
|
||||
image, valid = MapRenderer(archive).render(0, 0, 0, route)
|
||||
self.assertFalse(valid)
|
||||
self.assertEqual(image[128, 128], 128) # the route is still drawn
|
||||
with local_archive(make_archive(field(3, field(1, b'buildings')))) as archive: # tiles without roads
|
||||
self.assertFalse(MapRenderer(archive).render(0, 0, 0, route)[1])
|
||||
|
||||
|
||||
class TestRouteTracking(unittest.TestCase):
|
||||
def test_snap_heading_and_window(self):
|
||||
m = METER
|
||||
tracker = RouteTracker(np.array([[0, 0], [0, 200 * m], [200 * m, 200 * m]])) # north 200m, then east 200m
|
||||
longitude, latitude, bearing = tracker.snap(3 * m, 100 * m) # 3m right of the first leg
|
||||
self.assertAlmostEqual(tracker.progress, 100, delta=0.5)
|
||||
self.assertAlmostEqual(longitude, 0) # lateral error removed
|
||||
self.assertAlmostEqual(latitude, 100 * m)
|
||||
self.assertAlmostEqual(bearing, 0, places=3)
|
||||
self.assertAlmostEqual(tracker.snap(0, 200 * m)[2], 45, places=2) # at the corner the +-50m chord is mid-turn
|
||||
self.assertAlmostEqual(tracker.snap(0, 150 * m)[2], 45, places=2) # progress never decreases
|
||||
tracker.snap(100 * m, 200 * m) # progress 300: the window ahead no longer includes the first leg
|
||||
self.assertAlmostEqual(tracker.snap(150 * m, 200 * m)[0], 150 * m) # so the window's offset is applied
|
||||
self.assertAlmostEqual(tracker.snap(200 * m, 200 * m)[2], 90, places=2) # the chord clips at the route's end
|
||||
# Out and back along the same road: a fix near the far leg stays on the outbound leg within the window,
|
||||
# while a fresh tracker searches anywhere and lands on the return leg.
|
||||
route = np.array([[0, 0], [0, 1000 * m], [10 * m, 1000 * m], [10 * m, 0]])
|
||||
tracker = RouteTracker(route)
|
||||
tracker.snap(1 * m, 100 * m)
|
||||
self.assertAlmostEqual(tracker.snap(9 * m, 150 * m)[2], 0, places=3)
|
||||
self.assertAlmostEqual(tracker.progress, 150, delta=0.5)
|
||||
self.assertAlmostEqual(RouteTracker(route).snap(9 * m, 150 * m)[2], 180, places=3)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Byte ranges of the hosted PMTiles basemap, cached on disk."""
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from openpilot.common.api import Api
|
||||
from openpilot.common.hardware import PC
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.selfdrive.navigation.pmtiles import HEADER_LENGTH
|
||||
|
||||
CACHE_LIMIT = 64 * 1024 * 1024
|
||||
MERGE_GAP = 256 * 1024 # tiles this close together in the archive are fetched in one range request
|
||||
|
||||
|
||||
class RemoteTiles:
|
||||
def __init__(self):
|
||||
self.cache = Path(Paths.comma_home() if PC else '/data') / 'navigation' / 'tiles'
|
||||
self.cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
self.session = requests.Session()
|
||||
self.api = Api(Params().get('DongleId'))
|
||||
self.url = ''
|
||||
self.expires = 0
|
||||
self.etag = None
|
||||
self.size = 0
|
||||
version = self.cache / 'version'
|
||||
try:
|
||||
header = self._fetch(0, HEADER_LENGTH) # learns the archive's ETag and size
|
||||
if not version.exists() or version.read_text() != f'{self.etag} {self.size}':
|
||||
for cached in self.cache.iterdir(): # a new archive has new offsets
|
||||
cached.unlink()
|
||||
version.write_text(f'{self.etag} {self.size}')
|
||||
self._store(0, header)
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def _fetch(self, offset: int, length: int) -> bytes:
|
||||
try:
|
||||
if time.time() >= self.expires - 60: # noqa: TID251 - SAS expiry is a Unix timestamp
|
||||
response = self.api.get(f'v1/{self.api.dongle_id}/nav/tiles', access_token=self.api.get_token(), timeout=10)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
self.url, self.expires = metadata['url'], metadata['expires']
|
||||
headers = {'Range': f'bytes={offset}-{offset + length - 1}', 'Accept-Encoding': 'identity'}
|
||||
if self.etag is not None:
|
||||
headers['If-Match'] = self.etag
|
||||
with self.session.get(self.url, headers=headers, timeout=10, stream=True) as response:
|
||||
if response.status_code != 206:
|
||||
raise OSError(f'Map tile range request failed ({response.status_code})')
|
||||
match = re.fullmatch(r'bytes (\d+)-(\d+)/(\d+)', response.headers.get('Content-Range', ''))
|
||||
if match is None or tuple(map(int, match.groups()[:2])) != (offset, offset + length - 1):
|
||||
raise OSError('Invalid map tile range response')
|
||||
size = int(match[3])
|
||||
etag = response.headers.get('ETag')
|
||||
if not etag or (self.etag is not None and (etag != self.etag or size != self.size)):
|
||||
raise OSError('Map archive changed during read')
|
||||
data = bytearray()
|
||||
for chunk in response.iter_content(chunk_size=min(length + 1, 65536)):
|
||||
data.extend(chunk)
|
||||
if len(data) > length:
|
||||
raise OSError('Oversized map tile range response')
|
||||
if len(data) != length:
|
||||
raise OSError('Truncated map tile range response')
|
||||
self.size, self.etag = size, etag
|
||||
return bytes(data)
|
||||
except (requests.RequestException, ValueError, KeyError, TypeError, RuntimeError):
|
||||
# requests exceptions contain the signed URL; keep it out of logs.
|
||||
raise OSError('Unable to fetch map tiles') from None
|
||||
|
||||
def _store(self, offset: int, data: bytes):
|
||||
(self.cache / f'{offset}-{len(data)}').write_bytes(data)
|
||||
|
||||
def _trim(self):
|
||||
files = [(cached.stat().st_mtime, cached.stat().st_size, cached) for cached in self.cache.iterdir() if re.fullmatch(r'\d+-\d+', cached.name)]
|
||||
size = sum(cached_size for _, cached_size, _ in files)
|
||||
for _, cached_size, cached in sorted(files):
|
||||
if size <= CACHE_LIMIT:
|
||||
break
|
||||
cached.unlink(missing_ok=True)
|
||||
size -= cached_size
|
||||
|
||||
def read(self, offset: int, length: int) -> bytes:
|
||||
try:
|
||||
data = (self.cache / f'{offset}-{length}').read_bytes()
|
||||
if len(data) == length:
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
data = self._fetch(offset, length)
|
||||
self._store(offset, data)
|
||||
self._trim()
|
||||
return data
|
||||
|
||||
def prefetch(self, ranges):
|
||||
"""Cache (offset, length) ranges ahead of reads, fetching neighboring ones in a single request."""
|
||||
missing = sorted((offset, length) for offset, length in set(ranges) if not (self.cache / f'{offset}-{length}').exists())
|
||||
if not missing:
|
||||
return
|
||||
groups = [[missing[0]]]
|
||||
for offset, length in missing[1:]:
|
||||
last_offset, last_length = groups[-1][-1]
|
||||
if offset - (last_offset + last_length) <= MERGE_GAP:
|
||||
groups[-1].append((offset, length))
|
||||
else:
|
||||
groups.append([(offset, length)])
|
||||
for group in groups:
|
||||
start, end = group[0][0], group[-1][0] + group[-1][1]
|
||||
data = self._fetch(start, end - start)
|
||||
for offset, length in group:
|
||||
self._store(offset, data[offset - start:offset - start + length])
|
||||
self._trim()
|
||||
|
||||
def close(self):
|
||||
self.session.close()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Decode road polylines from Protomaps MVT tiles without a protobuf runtime."""
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.selfdrive.navigation.pmtiles import read_varint
|
||||
|
||||
|
||||
@dataclass
|
||||
class Road:
|
||||
points: np.ndarray # fractions of the tile, x right / y down
|
||||
kind: str
|
||||
detail: str
|
||||
tunnel: bool
|
||||
bridge: bool
|
||||
|
||||
|
||||
def fields(data: bytes):
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
tag, offset = read_varint(data, offset)
|
||||
number, wire = tag >> 3, tag & 7
|
||||
if number == 0:
|
||||
raise ValueError("Invalid protobuf field")
|
||||
if wire == 0:
|
||||
value, offset = read_varint(data, offset)
|
||||
elif wire in (1, 2, 5):
|
||||
if wire == 2:
|
||||
length, offset = read_varint(data, offset)
|
||||
else:
|
||||
length = 8 if wire == 1 else 4
|
||||
if offset + length > len(data):
|
||||
raise ValueError("Truncated protobuf field")
|
||||
value = data[offset:offset + length]
|
||||
offset += length
|
||||
else:
|
||||
raise ValueError(f"Unsupported protobuf wire type: {wire}")
|
||||
yield number, wire, value
|
||||
|
||||
|
||||
def integers(data: bytes) -> list[int]:
|
||||
result = []
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
value, offset = read_varint(data, offset)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def unzigzag(value: int) -> int:
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
|
||||
def decode_value(data: bytes):
|
||||
"""A string or boolean layer value. Other types are unused by the roads layer and decode to None."""
|
||||
for number, wire, value in fields(data):
|
||||
if number == 1 and wire == 2:
|
||||
return value.decode('utf-8')
|
||||
if number == 7 and wire == 0:
|
||||
return bool(value)
|
||||
return None
|
||||
|
||||
|
||||
def decode_lines(commands: list[int]) -> list[np.ndarray]:
|
||||
lines = []
|
||||
line = []
|
||||
x = y = offset = 0
|
||||
while offset < len(commands):
|
||||
command = commands[offset]
|
||||
offset += 1
|
||||
kind, count = command & 7, command >> 3
|
||||
if kind not in (1, 2) or count == 0 or offset + 2 * count > len(commands):
|
||||
raise ValueError("Invalid MVT line geometry")
|
||||
if kind == 1 and count != 1:
|
||||
raise ValueError("LineString MoveTo must contain one point")
|
||||
if kind == 2 and not line:
|
||||
raise ValueError("LineTo before MoveTo")
|
||||
for _ in range(count):
|
||||
x += unzigzag(commands[offset])
|
||||
y += unzigzag(commands[offset + 1])
|
||||
offset += 2
|
||||
if kind == 1:
|
||||
if len(line) >= 2:
|
||||
lines.append(np.array(line, dtype=np.float64))
|
||||
line = []
|
||||
line.append((x, y))
|
||||
if len(line) >= 2:
|
||||
lines.append(np.array(line, dtype=np.float64))
|
||||
return lines
|
||||
|
||||
|
||||
def decode_roads(data: bytes) -> list[Road]:
|
||||
roads = []
|
||||
for number, wire, layer in fields(data):
|
||||
if number != 3 or wire != 2:
|
||||
continue
|
||||
layer_fields = list(fields(layer))
|
||||
name = next((v.decode('utf-8') for n, w, v in layer_fields if n == 1 and w == 2), '')
|
||||
if name != 'roads':
|
||||
continue
|
||||
version = next((v for n, w, v in layer_fields if n == 15 and w == 0), 1)
|
||||
extent = next((v for n, w, v in layer_fields if n == 5 and w == 0), 4096)
|
||||
if version not in (1, 2) or extent <= 0:
|
||||
raise ValueError("Unsupported MVT roads layer")
|
||||
keys = [v.decode('utf-8') for n, w, v in layer_fields if n == 3 and w == 2]
|
||||
values = [decode_value(v) for n, w, v in layer_fields if n == 4 and w == 2]
|
||||
for n, w, feature in layer_fields:
|
||||
if n != 2 or w != 2:
|
||||
continue
|
||||
tags, geometry, geometry_type = [], [], 0
|
||||
for fn, fw, fv in fields(feature):
|
||||
if fn in (2, 4):
|
||||
target = tags if fn == 2 else geometry
|
||||
if fw == 2:
|
||||
target.extend(integers(fv))
|
||||
elif fw == 0:
|
||||
target.append(fv)
|
||||
elif fn == 3 and fw == 0:
|
||||
geometry_type = fv
|
||||
if geometry_type != 2:
|
||||
continue
|
||||
if len(tags) % 2:
|
||||
raise ValueError("Unpaired MVT tags")
|
||||
properties = {}
|
||||
for i in range(0, len(tags), 2):
|
||||
if tags[i] >= len(keys) or tags[i + 1] >= len(values):
|
||||
raise ValueError("MVT tag index out of bounds")
|
||||
properties[keys[tags[i]]] = values[tags[i + 1]]
|
||||
kind = properties.get('kind', '')
|
||||
if kind in ('highway', 'major_road', 'minor_road', 'other'):
|
||||
roads.extend(Road(points / extent, kind, properties.get('kind_detail', ''), properties.get('is_tunnel') is True,
|
||||
properties.get('is_bridge') is True) for points in decode_lines(geometry))
|
||||
return roads
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
|
||||
#define TIMEOUT 0
|
||||
#define SPI_BUF_SIZE 2048
|
||||
#define SPI_BUF_SIZE 4096
|
||||
|
||||
|
||||
class PandaSpiHandle {
|
||||
@@ -35,15 +35,18 @@ private:
|
||||
inline static std::recursive_mutex hw_lock;
|
||||
|
||||
struct __attribute__((packed)) spi_header {
|
||||
uint8_t sync;
|
||||
uint8_t endpoint;
|
||||
uint32_t id;
|
||||
uint16_t tx_len;
|
||||
uint16_t max_rx_len;
|
||||
uint16_t rx_len;
|
||||
};
|
||||
struct __attribute__((packed)) spi_resp_header {
|
||||
uint8_t status;
|
||||
uint32_t id;
|
||||
uint16_t len;
|
||||
};
|
||||
|
||||
int wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length);
|
||||
int bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout);
|
||||
int spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout);
|
||||
int spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline);
|
||||
int spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout);
|
||||
int lltransfer(struct spi_ioc_transfer &t);
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ void fill_panda_state(cereal::PandaState::Builder &ps, cereal::PandaState::Panda
|
||||
ps.setInterruptLoad(health.interrupt_load_pkt / 255.0f);
|
||||
ps.setFanPower(health.fan_power);
|
||||
ps.setSafetyRxChecksInvalid((health.flags_pkt & HEALTH_FLAG_SAFETY_RX_CHECKS_INVALID) != 0U);
|
||||
ps.setNmiReset((health.flags_pkt & HEALTH_FLAG_NMI_RESET) != 0U);
|
||||
ps.setHardfaultReset((health.flags_pkt & HEALTH_FLAG_HARDFAULT_RESET) != 0U);
|
||||
ps.setSpiErrorCount(health.spi_error_count_pkt);
|
||||
ps.setSbu1Voltage(health.sbu1_voltage_mV / 1000.0f);
|
||||
ps.setSbu2Voltage(health.sbu2_voltage_mV / 1000.0f);
|
||||
|
||||
+192
-225
@@ -2,10 +2,12 @@
|
||||
#include <sys/ioctl.h>
|
||||
#include <linux/spi/spidev.h>
|
||||
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
|
||||
#include "common/util.h"
|
||||
@@ -15,26 +17,26 @@
|
||||
#include "selfdrive/pandad/panda_comms.h"
|
||||
|
||||
|
||||
#define SPI_SYNC 0x5AU
|
||||
#define SPI_HACK 0x79U
|
||||
#define SPI_DACK 0x85U
|
||||
#define SPI_NACK 0x1FU
|
||||
#define SPI_CHECKSUM_START 0xABU
|
||||
// protocol, see panda/board/drivers/spi.h
|
||||
#define SPI_SLAVE_READY 0x3CU
|
||||
#define SPI_ACK 0xC3U
|
||||
#define SPI_NACK 0x5AU
|
||||
#define SPI_POLL 0xFFU // filler while reading the response, never a valid request
|
||||
|
||||
#define SPI_CRC_SIZE 4U
|
||||
#define SPI_XFER_SIZE (SPI_BUF_SIZE - 0x40) // bulk chunk size, leaves room for the protocol overhead
|
||||
#define SPI_FIRST_READ_SIZE (1024 - sizeof(spi_resp_header) - SPI_CRC_SIZE) // reads are usually small, start small
|
||||
|
||||
|
||||
enum SpiError {
|
||||
NACK = -2,
|
||||
ACK_TIMEOUT = -3,
|
||||
SILENT = -4,
|
||||
};
|
||||
|
||||
const unsigned int SPI_ACK_TIMEOUT = 500; // milliseconds
|
||||
const unsigned int SPI_MIN_TIMEOUT = 100; // milliseconds
|
||||
const double SPI_SILENT_TIMEOUT = 1000; // milliseconds, longer than the slowest request takes on the panda
|
||||
const std::string SPI_DEVICE = "/dev/spidev0.0";
|
||||
// TODO: fix SPI turnaround synchronization at the protocol level.
|
||||
static uint64_t spi_last_bus_activity_ns = 0; // protected by hw_lock
|
||||
|
||||
static void wait_for_spi_turnaround(uint64_t start_ns) {
|
||||
while ((nanos_since_boot() - start_ns) < 400000) {}
|
||||
}
|
||||
|
||||
class LockEx {
|
||||
public:
|
||||
@@ -56,8 +58,8 @@ private:
|
||||
#define SPILOG(fn, fmt, ...) do { \
|
||||
fn(fmt, ## __VA_ARGS__); \
|
||||
fn(" %d / 0x%x / %d / %d / tx: %s", \
|
||||
xfer_count, header.endpoint, header.tx_len, header.max_rx_len, \
|
||||
util::hexdump(tx_buf, std::min((int)header.tx_len, 8)).c_str()); \
|
||||
xfer_count, header.endpoint, header.tx_len, header.rx_len, \
|
||||
util::hexdump(tx_buf + sizeof(header), std::min((int)header.tx_len, 8)).c_str()); \
|
||||
} while (0)
|
||||
|
||||
PandaSpiHandle::PandaSpiHandle(std::string serial) {
|
||||
@@ -88,7 +90,7 @@ PandaSpiHandle::PandaSpiHandle(std::string serial) {
|
||||
util::safe_ioctl(spi_fd, SPI_IOC_WR_BITS_PER_WORD, &spi_bits_per_word, "failed setting SPI bits per word");
|
||||
|
||||
// get hw UID/serial
|
||||
ret = control_read(0xc3, 0, 0, uid, uid_len, 100);
|
||||
ret = control_read(0xc3, 0, 0, uid, uid_len, 500);
|
||||
if (ret == uid_len) {
|
||||
std::stringstream stream;
|
||||
for (int i = 0; i < uid_len; i++) {
|
||||
@@ -146,40 +148,31 @@ int PandaSpiHandle::control_read(uint8_t request, uint16_t param1, uint16_t para
|
||||
}
|
||||
|
||||
int PandaSpiHandle::bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
|
||||
return bulk_transfer(endpoint, data, length, NULL, 0, timeout);
|
||||
}
|
||||
int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
|
||||
return bulk_transfer(endpoint, NULL, 0, data, length, timeout);
|
||||
}
|
||||
|
||||
int PandaSpiHandle::bulk_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t rx_len, unsigned int timeout) {
|
||||
const int xfer_size = SPI_BUF_SIZE - 0x40;
|
||||
|
||||
int ret = 0;
|
||||
uint16_t length = (tx_data != NULL) ? tx_len : rx_len;
|
||||
for (int i = 0; i < (int)std::ceil((float)length / xfer_size); i++) {
|
||||
int d;
|
||||
if (tx_data != NULL) {
|
||||
int len = std::min(xfer_size, tx_len - (xfer_size * i));
|
||||
d = spi_transfer_retry(endpoint, tx_data + (xfer_size * i), len, NULL, 0, timeout);
|
||||
} else {
|
||||
uint16_t to_read = std::min(xfer_size, rx_len - ret);
|
||||
d = spi_transfer_retry(endpoint, NULL, 0, rx_data + (xfer_size * i), to_read, timeout);
|
||||
}
|
||||
|
||||
if (d < 0) {
|
||||
SPILOG(LOGE, "SPI: bulk transfer failed with %d", d);
|
||||
for (int pos = 0; pos < length; pos += SPI_XFER_SIZE) {
|
||||
int ret = spi_transfer_retry(endpoint, data + pos, std::min(SPI_XFER_SIZE, length - pos), NULL, 0, timeout);
|
||||
if (ret < 0) {
|
||||
comms_healthy = false;
|
||||
return d;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
ret += d;
|
||||
if ((rx_data != NULL) && d < xfer_size) {
|
||||
int PandaSpiHandle::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
|
||||
int pos = 0;
|
||||
while (pos < length) {
|
||||
int to_read = std::min(pos == 0 ? (int)SPI_FIRST_READ_SIZE : SPI_XFER_SIZE, length - pos);
|
||||
int ret = spi_transfer_retry(endpoint, NULL, 0, data + pos, to_read, timeout);
|
||||
if (ret < 0) {
|
||||
comms_healthy = false;
|
||||
return ret;
|
||||
}
|
||||
pos += ret;
|
||||
if (ret < to_read) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
return pos;
|
||||
}
|
||||
|
||||
std::vector<std::string> PandaSpiHandle::list() {
|
||||
@@ -192,228 +185,202 @@ std::vector<std::string> PandaSpiHandle::list() {
|
||||
return {};
|
||||
}
|
||||
|
||||
void add_checksum(uint8_t *data, int data_len) {
|
||||
data[data_len] = SPI_CHECKSUM_START;
|
||||
for (int i=0; i < data_len; i++) {
|
||||
data[data_len] ^= data[i];
|
||||
}
|
||||
// random, so it's never the same as another request the panda just handled, even from another client
|
||||
static uint32_t random_id() {
|
||||
static thread_local std::mt19937 rng{std::random_device{}()};
|
||||
return rng();
|
||||
}
|
||||
|
||||
bool check_checksum(uint8_t *data, int data_len) {
|
||||
uint8_t checksum = SPI_CHECKSUM_START;
|
||||
for (uint16_t i = 0U; i < data_len; i++) {
|
||||
checksum ^= data[i];
|
||||
// standard CRC-32, same as zlib's crc32()
|
||||
static uint32_t crc32(const uint8_t *data, uint32_t len) {
|
||||
static const auto table = [] {
|
||||
std::array<uint32_t, 256> t{};
|
||||
for (uint32_t i = 0; i < 256; i++) {
|
||||
uint32_t c = i;
|
||||
for (int j = 0; j < 8; j++) {
|
||||
c = (c & 1) ? (0xEDB88320U ^ (c >> 1)) : (c >> 1);
|
||||
}
|
||||
t[i] = c;
|
||||
}
|
||||
return t;
|
||||
}();
|
||||
|
||||
uint32_t crc = 0xFFFFFFFFU;
|
||||
for (uint32_t i = 0; i < len; i++) {
|
||||
crc = table[(crc ^ data[i]) & 0xFFU] ^ (crc >> 8);
|
||||
}
|
||||
return checksum == 0U;
|
||||
return crc ^ 0xFFFFFFFFU;
|
||||
}
|
||||
|
||||
static void put_crc(uint8_t *data, uint32_t len) {
|
||||
uint32_t crc = crc32(data, len);
|
||||
memcpy(&data[len], &crc, sizeof(crc));
|
||||
}
|
||||
|
||||
static bool check_crc(const uint8_t *data, uint32_t len) {
|
||||
uint32_t crc;
|
||||
memcpy(&crc, &data[len], sizeof(crc));
|
||||
return crc == crc32(data, len);
|
||||
}
|
||||
|
||||
|
||||
int PandaSpiHandle::spi_transfer_retry(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) {
|
||||
// timeout of 0 means forever
|
||||
const double deadline = (timeout == 0) ? INFINITY : millis_since_boot() + std::max(timeout, SPI_MIN_TIMEOUT);
|
||||
|
||||
// the id stays the same when we send the request again, so the panda never runs it twice
|
||||
uint32_t id = random_id();
|
||||
int ret;
|
||||
int nack_count = 0;
|
||||
int timeout_count = 0;
|
||||
bool timed_out = false;
|
||||
double start_time = millis_since_boot();
|
||||
|
||||
do {
|
||||
ret = spi_transfer(endpoint, tx_data, tx_len, rx_data, max_rx_len, timeout);
|
||||
|
||||
if (ret < 0) {
|
||||
timed_out = (timeout != 0) && (timeout_count > 5);
|
||||
timeout_count += ret == SpiError::ACK_TIMEOUT;
|
||||
|
||||
// give other threads a chance to run
|
||||
std::this_thread::yield();
|
||||
|
||||
if (ret == SpiError::NACK) {
|
||||
// prevent busy waiting while the panda is NACK'ing
|
||||
// due to full TX buffers
|
||||
nack_count += 1;
|
||||
if (nack_count > 3) {
|
||||
SPILOG(LOGD, "NACK sleep %d", nack_count);
|
||||
usleep(std::clamp(nack_count*10, 200, 2000));
|
||||
}
|
||||
}
|
||||
while (true) {
|
||||
ret = spi_transfer(endpoint, id, tx_data, tx_len, rx_data, max_rx_len, deadline);
|
||||
if (ret >= 0 || !connected) {
|
||||
break;
|
||||
}
|
||||
} while (ret < 0 && connected && !timed_out);
|
||||
|
||||
if (ret == SpiError::NACK) {
|
||||
// the panda NACKs while its CAN TX buffers are full. that's not a comms
|
||||
// failure, so keep trying regardless of the timeout, without busy waiting.
|
||||
// a new id, since the panda would answer the same id with the same NACK
|
||||
id = random_id();
|
||||
nack_count += 1;
|
||||
if (nack_count > 3) {
|
||||
usleep(std::clamp(nack_count*10, 200, 2000));
|
||||
}
|
||||
} else if (ret == SpiError::SILENT) {
|
||||
// the panda went quiet. let go of the bus for a bit, someone else might need it
|
||||
LOGW("SPI: panda isn't responding");
|
||||
usleep(10000);
|
||||
} else if (millis_since_boot() > deadline) {
|
||||
break;
|
||||
}
|
||||
|
||||
// give other threads a chance to run
|
||||
std::this_thread::yield();
|
||||
}
|
||||
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "transfer failed, after %d tries, %.2fms", timeout_count, millis_since_boot() - start_time);
|
||||
SPILOG(LOGE, "SPI: transfer failed with %d", ret);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
int PandaSpiHandle::wait_for_ack(uint8_t ack, uint8_t tx, unsigned int timeout, unsigned int length) {
|
||||
double start_millis = millis_since_boot();
|
||||
if (timeout == 0) {
|
||||
timeout = SPI_ACK_TIMEOUT;
|
||||
}
|
||||
timeout = std::clamp(timeout, 20U, SPI_ACK_TIMEOUT);
|
||||
|
||||
spi_ioc_transfer transfer = {
|
||||
.tx_buf = (uint64_t)tx_buf,
|
||||
.rx_buf = (uint64_t)rx_buf,
|
||||
.len = length,
|
||||
};
|
||||
memset(tx_buf, tx, length);
|
||||
|
||||
while (true) {
|
||||
int ret = lltransfer(transfer);
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "SPI: failed to send ACK request");
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (rx_buf[0] == ack) {
|
||||
break;
|
||||
} else if (rx_buf[0] == SPI_NACK) {
|
||||
SPILOG(LOGD, "SPI: got NACK, waiting for 0x%x", ack);
|
||||
return SpiError::NACK;
|
||||
}
|
||||
|
||||
// handle timeout
|
||||
if (millis_since_boot() - start_millis > timeout) {
|
||||
SPILOG(LOGW, "SPI: timed out waiting for ACK, waiting for 0x%x", ack);
|
||||
return SpiError::ACK_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int PandaSpiHandle::lltransfer(spi_ioc_transfer &t) {
|
||||
static const double err_prob = std::stod(util::getenv("SPI_ERR_PROB", "-1"));
|
||||
auto chance = [](double p) { return (static_cast<double>(rand()) / RAND_MAX) < p; };
|
||||
auto corrupt = [&](uint64_t buf) {
|
||||
for (uint32_t i = 0; i < t.len; i++) {
|
||||
if (chance(0.1)) ((uint8_t*)buf)[i] = (uint8_t)(rand() % 256);
|
||||
}
|
||||
};
|
||||
|
||||
if (err_prob > 0) {
|
||||
if ((static_cast<double>(rand()) / RAND_MAX) < err_prob) {
|
||||
printf("transfer len error\n");
|
||||
t.len = rand() % SPI_BUF_SIZE;
|
||||
}
|
||||
if ((static_cast<double>(rand()) / RAND_MAX) < err_prob && t.tx_buf != (uint64_t)NULL) {
|
||||
printf("corrupting TX\n");
|
||||
for (int i = 0; i < t.len; i++) {
|
||||
if ((static_cast<double>(rand()) / RAND_MAX) > 0.9) {
|
||||
((uint8_t*)t.tx_buf)[i] = (uint8_t)(rand() % 256);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (err_prob > 0 && chance(err_prob)) {
|
||||
printf("transfer len error\n");
|
||||
t.len = rand() % SPI_BUF_SIZE;
|
||||
}
|
||||
if (err_prob > 0 && chance(err_prob) && t.tx_buf == (uint64_t)tx_buf) {
|
||||
printf("corrupting TX\n");
|
||||
corrupt(t.tx_buf);
|
||||
}
|
||||
|
||||
int ret = util::safe_ioctl(spi_fd, SPI_IOC_MESSAGE(1), &t);
|
||||
|
||||
if (err_prob > 0) {
|
||||
if ((static_cast<double>(rand()) / RAND_MAX) < err_prob && t.rx_buf != (uint64_t)NULL) {
|
||||
printf("corrupting RX\n");
|
||||
for (int i = 0; i < t.len; i++) {
|
||||
if ((static_cast<double>(rand()) / RAND_MAX) > 0.9) {
|
||||
((uint8_t*)t.rx_buf)[i] = (uint8_t)(rand() % 256);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (err_prob > 0 && chance(err_prob)) {
|
||||
printf("ioctl error\n");
|
||||
ret = -1;
|
||||
}
|
||||
|
||||
if (err_prob > 0 && chance(err_prob)) {
|
||||
printf("corrupting RX\n");
|
||||
corrupt(t.rx_buf);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, unsigned int timeout) {
|
||||
int ret;
|
||||
uint16_t rx_data_len;
|
||||
// One request and its response. Resends until the panda got the request and we got its response,
|
||||
// or the deadline. Every transfer either sees all of what the panda queued from its first byte, or
|
||||
// nothing it can mistake for a response. Returns the response length, or a negative SpiError
|
||||
int PandaSpiHandle::spi_transfer(uint8_t endpoint, uint32_t id, uint8_t *tx_data, uint16_t tx_len, uint8_t *rx_data, uint16_t max_rx_len, double deadline) {
|
||||
static const auto poll = [] {
|
||||
std::array<uint8_t, SPI_BUF_SIZE> p;
|
||||
p.fill(SPI_POLL);
|
||||
return p;
|
||||
}();
|
||||
|
||||
LockEx lock(spi_fd, hw_lock);
|
||||
|
||||
// needs to be less, since we need to have space for the checksum
|
||||
assert(tx_len < SPI_BUF_SIZE);
|
||||
assert(max_rx_len < SPI_BUF_SIZE);
|
||||
|
||||
wait_for_spi_turnaround(spi_last_bus_activity_ns);
|
||||
// the response is always read in one transfer of rx_len
|
||||
const uint32_t rx_len = sizeof(spi_resp_header) + max_rx_len + SPI_CRC_SIZE;
|
||||
const uint32_t req_len = sizeof(header) + tx_len;
|
||||
assert(req_len + SPI_CRC_SIZE <= SPI_BUF_SIZE);
|
||||
assert(rx_len <= SPI_BUF_SIZE);
|
||||
|
||||
xfer_count++;
|
||||
header = {
|
||||
.sync = SPI_SYNC,
|
||||
.endpoint = endpoint,
|
||||
.id = id,
|
||||
.tx_len = tx_len,
|
||||
.max_rx_len = max_rx_len
|
||||
.rx_len = (uint16_t)rx_len,
|
||||
};
|
||||
|
||||
spi_ioc_transfer transfer = {
|
||||
.tx_buf = (uint64_t)tx_buf,
|
||||
.rx_buf = (uint64_t)rx_buf
|
||||
auto xfer = [&](const uint8_t *tx, uint32_t len) {
|
||||
spi_ioc_transfer t = {.tx_buf = (uint64_t)tx, .rx_buf = (uint64_t)rx_buf, .len = len};
|
||||
int ret = lltransfer(t);
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "SPI: transfer failed");
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
// Send header
|
||||
memcpy(tx_buf, &header, sizeof(header));
|
||||
add_checksum(tx_buf, sizeof(header));
|
||||
transfer.len = sizeof(header) + 1;
|
||||
ret = lltransfer(transfer);
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "SPI: failed to send header");
|
||||
goto fail;
|
||||
}
|
||||
// when the panda last showed it's there. while it's quiet, we let go of the bus
|
||||
double alive = millis_since_boot();
|
||||
while (true) {
|
||||
// send the request, until the panda was ready for it. it's rebuilt every time,
|
||||
// since SPI_ERR_PROB corrupts tx_buf
|
||||
memcpy(tx_buf, &header, sizeof(header));
|
||||
if (tx_data != NULL) {
|
||||
memcpy(tx_buf + sizeof(header), tx_data, tx_len);
|
||||
}
|
||||
put_crc(tx_buf, req_len);
|
||||
// a failed transfer is sent again like any other, keeping the bus, so nothing can run in between
|
||||
if (xfer(tx_buf, req_len + SPI_CRC_SIZE) >= 0 && rx_buf[0] == SPI_SLAVE_READY) {
|
||||
alive = millis_since_boot();
|
||||
// read until the response. if the panda is ready again, it never got the request
|
||||
do {
|
||||
if (xfer(poll.data(), rx_len) < 0) {
|
||||
break;
|
||||
}
|
||||
if (rx_buf[0] == SPI_SLAVE_READY) {
|
||||
alive = millis_since_boot();
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait for (N)ACK
|
||||
ret = wait_for_ack(SPI_HACK, 0x11, timeout, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
wait_for_spi_turnaround(nanos_since_boot());
|
||||
// a response that got cut off or corrupted isn't one, keep reading
|
||||
spi_resp_header resp;
|
||||
memcpy(&resp, rx_buf, sizeof(resp));
|
||||
if ((resp.status == SPI_ACK || resp.status == SPI_NACK) && resp.len <= max_rx_len && check_crc(rx_buf, sizeof(resp) + resp.len)) {
|
||||
// otherwise it's the response to an old request, and we send ours again
|
||||
if (resp.id == header.id) {
|
||||
if (resp.status == SPI_NACK) {
|
||||
SPILOG(LOGD, "SPI: got NACK");
|
||||
return SpiError::NACK;
|
||||
}
|
||||
if (rx_data != NULL) {
|
||||
memcpy(rx_data, rx_buf + sizeof(resp), resp.len);
|
||||
}
|
||||
return resp.len;
|
||||
}
|
||||
alive = millis_since_boot();
|
||||
break;
|
||||
}
|
||||
} while (millis_since_boot() < std::min(deadline, alive + SPI_SILENT_TIMEOUT));
|
||||
}
|
||||
|
||||
// Send data
|
||||
if (tx_data != NULL) {
|
||||
memcpy(tx_buf, tx_data, tx_len);
|
||||
}
|
||||
add_checksum(tx_buf, tx_len);
|
||||
transfer.len = tx_len + 1;
|
||||
ret = lltransfer(transfer);
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "SPI: failed to send data");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// Wait for (N)ACK
|
||||
ret = wait_for_ack(SPI_DACK, 0x13, timeout, 3);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// Read data
|
||||
rx_data_len = *(uint16_t *)(rx_buf+1);
|
||||
if (rx_data_len >= SPI_BUF_SIZE) {
|
||||
SPILOG(LOGE, "SPI: RX data len larger than buf size %d", rx_data_len);
|
||||
goto fail;
|
||||
}
|
||||
|
||||
transfer.len = rx_data_len + 1;
|
||||
transfer.rx_buf = (uint64_t)(rx_buf + 2 + 1);
|
||||
ret = lltransfer(transfer);
|
||||
if (ret < 0) {
|
||||
SPILOG(LOGE, "SPI: failed to read rx data");
|
||||
goto fail;
|
||||
}
|
||||
if (!check_checksum(rx_buf, rx_data_len + 4)) {
|
||||
SPILOG(LOGE, "SPI: bad checksum");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
if (rx_data != NULL) {
|
||||
memcpy(rx_data, rx_buf + 3, rx_data_len);
|
||||
}
|
||||
|
||||
spi_last_bus_activity_ns = nanos_since_boot();
|
||||
return rx_data_len;
|
||||
|
||||
fail:
|
||||
// ensure slave is in a consistent state
|
||||
// and ready for the next transfer
|
||||
int nack_cnt = 0;
|
||||
while (nack_cnt < 3) {
|
||||
if (wait_for_ack(SPI_NACK, 0x14, 1, SPI_BUF_SIZE/2) == 0) {
|
||||
nack_cnt += 1;
|
||||
} else {
|
||||
nack_cnt = 0;
|
||||
if (millis_since_boot() > deadline) {
|
||||
SPILOG(LOGW, "SPI: timed out");
|
||||
return SpiError::ACK_TIMEOUT;
|
||||
}
|
||||
if (millis_since_boot() > alive + SPI_SILENT_TIMEOUT) {
|
||||
return SpiError::SILENT;
|
||||
}
|
||||
}
|
||||
|
||||
spi_last_bus_activity_ns = nanos_since_boot();
|
||||
if (ret >= 0) ret = -1;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -45,9 +45,10 @@ class TestBoarddSpi(OpenpilotTestCase):
|
||||
|
||||
st = time.monotonic()
|
||||
ts = {s: [] for s in socks.keys()}
|
||||
for _ in range(int(os.getenv("TEST_TIME", "20"))):
|
||||
# send some CAN messages
|
||||
if not JUNGLE_SPAM:
|
||||
test_time = int(os.getenv("TEST_TIME", "20"))
|
||||
for i in range(test_time + 2):
|
||||
# send some CAN messages, then give the last ones time to come back
|
||||
if not JUNGLE_SPAM and i < test_time:
|
||||
sent = send_random_can_messages(sendcan, random.randrange(2, 20))
|
||||
for k, v in sent.items():
|
||||
sent_msgs[k].extend(list(v))
|
||||
@@ -74,8 +75,7 @@ class TestBoarddSpi(OpenpilotTestCase):
|
||||
continue
|
||||
key = (msg.address, msg.dat)
|
||||
assert key in sent_msgs[msg.src], f"got unexpected msg: {msg.src=} {msg.address=} {msg.dat=}"
|
||||
# TODO: enable this
|
||||
#sent_msgs[msg.src].remove(key)
|
||||
sent_msgs[msg.src].remove(key)
|
||||
total_recv_count += 1
|
||||
elif service == "pandaStates":
|
||||
assert len(m.pandaStates) == 1
|
||||
@@ -107,6 +107,8 @@ class TestBoarddSpi(OpenpilotTestCase):
|
||||
with subtests.test(msg="CAN traffic"):
|
||||
print(f"Sent {total_sent_count} CAN messages, got {total_recv_count} back. {total_recv_count/(total_sent_count+1e-4):.2%} received")
|
||||
assert total_recv_count > 20
|
||||
# every message came back exactly once, despite the injected errors
|
||||
assert all(len(v) == 0 for v in sent_msgs.values())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
"text": "Chestnut detected! Switch to the %1 branch to use chestnut-class models.",
|
||||
"severity": -1
|
||||
},
|
||||
"Offroad_ChestnutModelError": {
|
||||
"text": "Chestnut model error. Restart the car to retry.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_ChestnutNotDetected": {
|
||||
"text": "Chestnut not detected. Check USB and 12V connections.",
|
||||
"text": "Chestnut not detected. Check USB and 12V connections, then restart the car.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_ChestnutOverheated": {
|
||||
"text": "Chestnut overheated. Ensure good airflow. Current GPU temperature is %1.",
|
||||
"text": "Chestnut overheated. Ensure good airflow.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_ChestnutPcieUnavailable": {
|
||||
"text": "%1",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_ChestnutUncompiled": {
|
||||
"text": "Chestnut model not compiled. Keep ignition on and reboot the comma.",
|
||||
"severity": 0
|
||||
},
|
||||
"Offroad_ChestnutUpdateFailed": {
|
||||
"text": "Chestnut update failed. Check the USB cable.",
|
||||
"severity": 0
|
||||
@@ -49,6 +49,10 @@
|
||||
"text": "Failed to register with comma.ai backend. It will not connect or upload to comma.ai servers, and receives no support from comma.ai. If this is a device purchased at comma.ai/shop, open a ticket at https://comma.ai/support.",
|
||||
"severity": 1
|
||||
},
|
||||
"Offroad_UploadsIgnored": {
|
||||
"text": "Uploads ignored due to a potential detected safety policy violation. Contact comma.ai/support if you believe this was made in error.",
|
||||
"severity": 1
|
||||
},
|
||||
"Offroad_CarUnrecognized": {
|
||||
"text": "sunnypilot was unable to identify your car. Your car is either unsupported or its ECUs are not recognized. Please select your vehicle manually at https://www.sunnylink.ai/. Need help? Visit https://community.sunnypilot.ai/",
|
||||
"severity": 0
|
||||
@@ -74,5 +78,10 @@
|
||||
"text": "<b>Unsupported branch detected</b> - The current version of <b><u>%1</u></b> branch is no longer supported on the comma three. Please go to <b>[Device > Software]</b> and install a supported branch with <b><u>-tici</u></b> in the branch name for the comma three.",
|
||||
"severity": 1,
|
||||
"_comment": "Set extra field to the current branch name."
|
||||
},
|
||||
"Offroad_Pairing": {
|
||||
"text": "Finish setup. Pair your device with comma connect to view recent drives and bookmarked routes.",
|
||||
"severity": -2,
|
||||
"icon": "icons_mici/settings/device/green_settings.png"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ def longitudinal_maneuver_alert(CP: car.CarParams, CS: car.CarState, sm: messagi
|
||||
|
||||
def personality_changed_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
|
||||
personality = str(personality).title()
|
||||
return NormalPermanentAlert(f"Driving Personality: {personality}", duration=1.5)
|
||||
return NormalPermanentAlert(f"Following Distance: {personality}", duration=1.5)
|
||||
|
||||
|
||||
def invalid_lkas_setting_alert(CP: car.CarParams, CS: car.CarState, sm: messaging.SubMaster, metric: bool, soft_disable_time: int, personality) -> Alert:
|
||||
@@ -833,6 +833,10 @@ EVENTS: dict[int, dict[str, Alert | AlertCallbackType]] = {
|
||||
EventName.userBookmark: {
|
||||
ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", duration=1.5),
|
||||
},
|
||||
|
||||
EventName.userBookmarkNotPaired: {
|
||||
ET.PERMANENT: NormalPermanentAlert("Bookmark Saved", "pair to connect\nto view", duration=1.5),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ class SelfdriveD(CruiseHelper):
|
||||
# cleanup old params
|
||||
if not self.CP.alphaLongitudinalAvailable:
|
||||
self.params.remove("AlphaLongitudinalEnabled")
|
||||
if not self.CP.openpilotLongitudinalControl:
|
||||
self.params.remove("ExperimentalMode")
|
||||
|
||||
self.CS_prev = car.CarState.new_message()
|
||||
self.AM = AlertManager()
|
||||
@@ -209,6 +207,7 @@ class SelfdriveD(CruiseHelper):
|
||||
big_failed = big_active is False or model_unavailable or (self.big_model_active and not chestnut_present)
|
||||
if big_failed and not self.big_model_failed:
|
||||
self.events.add(EventName.bigModelFailed)
|
||||
self.big_model_ready_t = time.monotonic()
|
||||
self.big_model_failed = big_failed
|
||||
|
||||
# soft disable if the big model fails
|
||||
@@ -236,7 +235,9 @@ class SelfdriveD(CruiseHelper):
|
||||
|
||||
# Check for user bookmark press
|
||||
if self.sm.updated['userBookmark']:
|
||||
self.events.add(EventName.userBookmark)
|
||||
prime_type = self.params.get("PrimeType")
|
||||
paired = prime_type is not None and int(prime_type) >= 0
|
||||
self.events.add(EventName.userBookmark if paired else EventName.userBookmarkNotPaired)
|
||||
|
||||
# Don't add any more events while in dashcam mode
|
||||
if self.CP.passive:
|
||||
@@ -506,7 +507,7 @@ class SelfdriveD(CruiseHelper):
|
||||
|
||||
# TODO: fix simulator
|
||||
if not SIMULATION or REPLAY:
|
||||
if self.sm['modelV2'].frameDropPerc > 1:
|
||||
if self.sm['modelV2'].frameDropPerc > 1 and not big_model_settling:
|
||||
self.events.add(EventName.modeldLagging)
|
||||
|
||||
# mute canBusMissing event if in Park, as it sometimes may trigger a false alarm with MADS in Paused state
|
||||
@@ -577,7 +578,7 @@ class SelfdriveD(CruiseHelper):
|
||||
clear_event_types = set()
|
||||
if ET.WARNING not in self.state_machine.current_alert_types:
|
||||
clear_event_types.add(ET.WARNING)
|
||||
if self.enabled:
|
||||
if self.enabled or (ET.NO_ENTRY not in (c := self.state_machine.current_alert_types) and (ET.ENABLE in c or ET.USER_DISABLE in c)):
|
||||
clear_event_types.add(ET.NO_ENTRY)
|
||||
|
||||
pers = LONGITUDINAL_PERSONALITY_MAP[self.personality]
|
||||
@@ -599,7 +600,6 @@ class SelfdriveD(CruiseHelper):
|
||||
ss.active = self.active
|
||||
ss.state = self.state_machine.state
|
||||
ss.engageable = not self.events.contains(ET.NO_ENTRY)
|
||||
ss.experimentalMode = self.experimental_mode
|
||||
ss.personality = self.personality
|
||||
|
||||
ss.alertText1 = self.AM.current_alert.alert_text_1
|
||||
@@ -666,7 +666,6 @@ class SelfdriveD(CruiseHelper):
|
||||
self.is_metric = self.params.get_bool("IsMetric")
|
||||
self.is_ldw_enabled = self.params.get_bool("IsLdwEnabled")
|
||||
self.disengage_on_accelerator = self.params.get_bool("DisengageOnAccelerator")
|
||||
self.experimental_mode = self.params.get_bool("ExperimentalMode") and self.CP.openpilotLongitudinalControl
|
||||
self.personality = self.params.get("LongitudinalPersonality", return_default=True)
|
||||
|
||||
self.mads.read_params()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user