diff --git a/.cursor/rules/vdsim.mdc b/.cursor/rules/vdsim.mdc index d57185f5..6fade624 100644 --- a/.cursor/rules/vdsim.mdc +++ b/.cursor/rules/vdsim.mdc @@ -6,7 +6,7 @@ alwaysApply: true # VDSim — conventions & guardrails (any Cursor agent must follow) Before working, read `docs/HANDOFF.md` (current state / next task) and the relevant -`docs/design/*.md` (the spec). Cursor-usage rules: `docs/design/CURSOR_USAGE.md`. +`docs/design/*.md` (the spec). ## Conventions - Wheel index order: FL=0, FR=1, RL=2, RR=3 (all code/docs/plots). diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 926a40f5..a7e64863 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,6 +6,13 @@ on: pull_request: branches: [main] +env: + # Single pinned CMake for every job in this workflow, matching the version + # recorded on the `toolchain:` line of docs/VALIDATION.md. Never `latest`: + # a toolchain bump is a re-measurement trigger, so it has to be an explicit + # edit that lands in the same commit as the refreshed numbers. + VDSIM_CMAKE_VERSION: 3.31.10 + jobs: build: runs-on: ubuntu-22.04 @@ -39,12 +46,16 @@ jobs: - name: Install Python dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install "pybind11==2.13.6" numpy pyyaml + python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow - name: Report toolchain run: | python3 --version python3 -c "import pybind11, numpy, yaml; print('pybind11', pybind11.__version__); print('numpy', numpy.__version__); print('pyyaml', yaml.__version__)" + - name: Pin CMake + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: ${{ env.VDSIM_CMAKE_VERSION }} - name: Cache FetchContent uses: actions/cache@v4 @@ -69,8 +80,91 @@ jobs: # optimisation, so they are given the budget that matches what they are, # not an exemption: the assert still runs and still fails on a real # regression. - - name: Test + - name: Test (Debug budget) + if: matrix.build_type == 'Debug' working-directory: build env: - VDSIM_PERF_BUDGET_S: ${{ matrix.build_type == 'Debug' && '10.0' || '1.0' }} + VDSIM_PERF_BUDGET_S: '10.0' run: ctest --output-on-failure + + # Release states no budget: it runs on the defaults compiled into the + # tests, which are the Release contract. Injecting a value here would + # quietly relax the only configuration that can detect a real regression. + - name: Test + if: matrix.build_type != 'Debug' + working-directory: build + run: ctest --output-on-failure + + # Canonical configuration (the `validation` CMake preset). This is the only + # job whose ctest numbers docs/VALIDATION.md is allowed to record, and the + # gate below fails if the document no longer matches what was measured. + # See VDSimMarketing 07_p0_demo.md 6.4 / 6.5 / 6.6. + validation: + runs-on: ubuntu-22.04 + name: validation preset / currency gate + + steps: + - uses: actions/checkout@v4 + with: + # Full history so the currency gate can test whether the commit + # recorded in docs/VALIDATION.md is an ancestor of what was built. + fetch-depth: 0 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ninja-build python3-dev + + # The validation preset builds the Python bindings, and the canonical + # suite includes the catalog, plant and trace/render tests, so the same + # pinned dependency set as the build job has to be present here too. + - name: Install Python dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow + + - name: Pin CMake + # CMakePresets.json is version 3, which needs CMake >= 3.21, so the + # runner image default is deliberately not relied upon. + uses: jwlawson/actions-setup-cmake@v2 + with: + cmake-version: ${{ env.VDSIM_CMAKE_VERSION }} + + - name: Record toolchain + run: cmake --version | tee cmake-version.txt + + - name: Cache FetchContent + uses: actions/cache@v4 + with: + path: build-validation/_deps + key: ${{ runner.os }}-deps-validation-${{ hashFiles('third_party/CMakeLists.txt') }} + + - name: Configure (validation preset) + run: cmake --preset validation -Dpybind11_DIR="$(python3 -m pybind11 --cmakedir)" + + - name: Build (validation preset) + run: cmake --build --preset validation + + - name: List registered tests + run: ctest --preset validation -N | tee ctest-list.txt + + - name: Run tests + id: run + continue-on-error: true + run: ctest --preset validation | tee ctest-run.txt + + - name: VALIDATION.md currency gate + run: | + python3 scripts/check_validation_currency.py \ + --doc docs/VALIDATION.md \ + --ctest-list ctest-list.txt \ + --ctest-run ctest-run.txt \ + --cmake-version-file cmake-version.txt \ + --commit "${{ github.sha }}" \ + --repo . + + - name: Fail if the suite did not pass + if: steps.run.outcome != 'success' + run: | + echo "ctest reported failures in the canonical configuration" + exit 1 diff --git a/.gitignore b/.gitignore index 52767d89..cd67367f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Build directories build/ build-*/ +build_*/ out/ cmake-build-*/ @@ -76,6 +77,7 @@ apps/jump_demo/*/frames/ *.tir # Exception: public synthetic coefficients for the Chrono Pac02 parity gate (no measured data) !external/chrono_parity/sample_pac02.tir +!configs/parts/tire/ioniq5_pac2002.tir # Chrono parity build artifacts (the reference CSV IS committed; these are not) external/chrono_parity/build/ external/chrono_parity/_pac02_chrono.json @@ -91,3 +93,11 @@ apps/validation/results/ # Catalog: generated resolve cache + GUI-runtime-registered package stub configs/.resolve_cache/ configs/catalog/packages/gui_custom/ + +# Per-agent / personal tooling config (not part of the open-core product) +AGENTS.md +.cursor/rules/00-personalize.mdc +.cursor/rules/copyedit-en.mdc +.cursor/rules/copyedit-ko.mdc +.cursor/rules/over-eng-review.mdc +.cursor/rules/temp-debt.mdc diff --git a/CHANGELOG.md b/CHANGELOG.md index d037b566..d3825287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ All notable changes to VDSim are documented here. Format follows ## [Unreleased] +### Added — `.vdtrace` run container + headless trace renderer +- **`.vdtrace` container** (`python/vdsim_trace.py`): one zip per simulation run — + `manifest.json` + `channels/.f64` (C-order little-endian float64, stored + uncompressed so loading is a `np.frombuffer` reshape) + `overlays/.json`. + Ten channels (`t`, `pose`, `v_body`, `yaw_rate`, `u_steer`, `u_fx`, `wheel_F`, + `wheel_mu`, `wheel_kappa`, `wheel_alpha`); the channel table is self-describing, + so consumers select fields from the manifest instead of hardcoding a list. +- **Opt-in recording** (`VDSimPlant.enable_trace(path, decimation=N)` / + `finalize_trace()`), default OFF. `decimation=None` targets a 100 Hz record rate, + so a 1 kHz control loop stores 1 sample in 10 and a 20 Hz loop stores all of them. +- **Overlay pass-through** (`vdsim_trace.attach_overlay`): reference paths, lateral + error, events and regions are attached after the run and stored verbatim. VDSim + validates the `kind`/`name` envelope and never interprets the content, so + scenario concepts stay out of the plant. +- **Reproduction metadata**: `repro` carries `vdsim_version`, `git_sha`, `param_hash`, + `seed`, `dt_s` and `run_id`. `param_hash` covers plant parameters only (vehicle, + tyre, road mu, dt) — runs of the same vehicle over different scenarios share a hash. +- **Headless renderer** (`python/vdsim_render.py`, `vdsim-render` CLI): BEV tracking + camera with driven path, body rectangle, steered front wheels, velocity arrow, + fixed HUD, per-wheel friction-utilization colour and a command time-series panel + with a time cursor. GIF + preview PNG through the existing `[plot]` extra; MP4 only + when `imageio-ffmpeg` is present. No GUI, no node/npm/browser. +- **Friction utilization is derived, not recorded**: + `sqrt((Fx/(k_lon*mu*Fz))^2 + (Fy/(k_lat*mu*Fz))^2)` with `k_*` from manifest + `tire.mu_aniso`, measured per tyre by `vdsim_plant.measure_mu_aniso()`. A trace + without a `tire` block is rejected rather than defaulted to `[1, 1]`, which would + under-report saturation on an elliptic tyre. +- `examples/demo_grip_loss.py` now records a trace and renders it — the contract's + first consumer, and its self-check. +- Tests `trace_roundtrip` / `trace_render` on a committed golden fixture + (`tests/fixtures/trace/golden_v0_1.vdtrace`); both run without the compiled core. + Recording OFF costs -0.4 % of `step()` vs. the pre-hook baseline (gate: <= 1 %), + ON at the default decimation +0.9 %; a 30 s trace renders in 51 s at 20 fps + (`tools/bench_trace_overhead.py`). + ### Added — load-dependent Re, camber contact migration & inverted tire interface - **Effective rolling radius `Re(Fz)`** (Pacejka BREFF/DREFF/FREFF) across L1/L2/L3/L5: slip `= (omega*Re - vx)/vx`, so a free-rolling loaded tire reports `kappa = 0` (matches diff --git a/CLAUDE.md b/CLAUDE.md index 9fb1608d..e25d84c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,4 +14,4 @@ ## 빌드 - CMake (CMakeLists.txt). docs = mkdocs (github.io 배포) -- 공통: ~/.claude/CLAUDE.md (wheel FL=0, ISO 8855) +- 전역 규약: wheel index FL=0/FR=1/RL=2/RR=3, ISO 8855 RH (+x fwd, +y left, +z up) — 상세는 .cursor/rules/vdsim.mdc diff --git a/CMakeLists.txt b/CMakeLists.txt index 36a7ad9c..e5218726 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,11 @@ option(VDSIM_BUILD_PYTHON "Build Python bindings" OFF) option(VDSIM_BUILD_CARLA_PLUGIN "Build CARLA integration" ON) option(VDSIM_BUILD_COSIM "Build UDP co-simulation server" ON) option(VDSIM_WITH_ERGACCESS "Build with CarMaker ERGAccess" OFF) +# GUI v3 is a deferred (node-stack) component. Its whole test group is outside +# the canonical `validation` preset; see the `excluded:` lines in +# docs/VALIDATION.md. Exclusion is per component, not per target, so future +# GUI v3 tests attach to this same switch (07_p0_demo.md 6.4(2)). +option(VDSIM_BUILD_GUI_V3_TESTS "Register the GUI v3 test group" OFF) if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") add_compile_options(-Wall -Wextra -Wpedantic -Wno-unused-parameter) @@ -70,3 +75,4 @@ message(STATUS "Build examples : ${VDSIM_BUILD_EXAMPLES}") message(STATUS "Build Python : ${VDSIM_BUILD_PYTHON}") message(STATUS "Build CARLA plugin : ${VDSIM_BUILD_CARLA_PLUGIN}") message(STATUS "ERGAccess : ${VDSIM_WITH_ERGACCESS}") +message(STATUS "GUI v3 tests : ${VDSIM_BUILD_GUI_V3_TESTS}") diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 00000000..fb1f0c19 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,33 @@ +{ + "version": 3, + "cmakeMinimumRequired": { "major": 3, "minor": 21, "patch": 0 }, + "configurePresets": [ + { + "name": "validation", + "displayName": "Canonical validation configuration", + "description": "The single configuration whose ctest count docs/VALIDATION.md is allowed to record. See VDSimMarketing 07_p0_demo.md 6.4.", + "binaryDir": "${sourceDir}/build-validation", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "VDSIM_BUILD_TESTS": "ON", + "VDSIM_BUILD_EXAMPLES": "ON", + "VDSIM_BUILD_PYTHON": "ON", + "VDSIM_BUILD_COSIM": "ON", + "VDSIM_BUILD_CARLA_PLUGIN": "ON", + "VDSIM_WITH_ERGACCESS": "OFF", + "VDSIM_BUILD_GUI_V3_TESTS": "OFF" + } + } + ], + "buildPresets": [ + { "name": "validation", "configurePreset": "validation", "jobs": 0 } + ], + "testPresets": [ + { + "name": "validation", + "configurePreset": "validation", + "output": { "outputOnFailure": true }, + "execution": { "noTestsAction": "error" } + } + ] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c9254a74..a2407f7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,10 +8,23 @@ openly. ```bash cmake -DVDSIM_BUILD_PYTHON=ON -B build && cmake --build build -j -cd build && ctest --output-on-failure # 191 checks, must stay 100% green +cd build && ctest --output-on-failure # must stay 100% green ``` +Before quoting a suite count anywhere, reproduce it in the canonical +configuration — that is the only one [docs/VALIDATION.md](docs/VALIDATION.md) +records, and the `validation_currency` test fails if the two disagree: +```bash +cmake --preset validation && cmake --build --preset validation && ctest --preset validation +``` +Building VDSim needs CMake ≥ 3.16; reading `CMakePresets.json` (schema v3) needs +**CMake ≥ 3.21**, so the canonical run has the higher floor. CI pins one exact +CMake (`VDSIM_CMAKE_VERSION` in `.github/workflows/build.yml`) and the same +version is used on the measurement host — changing it means re-measuring in the +same commit. + Python package: `pip install ".[plot]"` (Python ≥ 3.10 with a modern `pip`). -CI (GitHub Actions) builds gcc-9 + clang-10 and runs ctest on every push/PR. +CI (GitHub Actions) builds gcc-11 + clang-14 on Ubuntu 22.04 and runs ctest on +every push/PR. ## Conventions (non-negotiable — they keep results comparable) diff --git a/README.ko.md b/README.ko.md index 906e08b2..eb2aea97 100644 --- a/README.ko.md +++ b/README.ko.md @@ -19,7 +19,23 @@ 너머 포화(drift>1) — soft-clamp가 아닌 실제 grip loss.* 재현: `python examples/demo_grip_loss.py` (`pip install vdsim[plot]` 또는 로컬 wheel). -Open-core 차량동역학 시뮬레이터. VDSim 은 **차량 그 자체**를 담당합니다 — 검증된 +> **Experimental / pre-release software — not for production use.** +> What is verified, and what is *not yet*: see [docs/VALIDATION.md](docs/VALIDATION.md). + +**Verification scope.** VDSim's tire-force layer is cross-checked against two independent +implementations driven by the same `.tir` parameter file: CarMaker MF-Tyre/MF-Swift +(pure longitudinal 0.00%, pure lateral 0.09%) and Chrono Pac02 (BSD-3, ~0.8% / 0.7%). +This is a pure-slip cross-check between implementations, not product parity. +Full-vehicle behaviour (suspension, transient) and comparison against real-vehicle +measurements are **not yet** covered. + +![Grip-loss demo](docs/assets/demo_grip_loss.gif) + +*결정론적 VDSim plant: 컨트롤러가 사전에 모르는 저-μ patch를 통과하며 타이어가 peak +너머 포화(drift>1) — soft-clamp가 아닌 실제 grip loss.* 재현: +`python examples/demo_grip_loss.py` (`pip install vdsim[plot]` 또는 로컬 wheel). + +Open-core 차량동역학 시뮬레이터. VDSim 은 **차량 그 자체**를 담당합니다 — 구현된 L1–L5 동역학 + 실제 Pacejka MF / LuGre / belt-transient 타이어 + 설계검증 가능한 hardpoint 서스펜션 운동학 + 양방향 FMI 2.0. 렌더링·센서는 CARLA 같은 도구에 위임합니다. perception 스택에 없는 "섀시 정확도" 절반을 채웁니다. @@ -51,9 +67,12 @@ python -c "import vdsim; from vdsim_lab import Sim; Sim(vehicle='sedan', level=' ## 소스 빌드 -전제: C++17 컴파일러, CMake ≥ 3.20, Python ≥ 3.10. -- Linux: `g++ ≥ 9` 또는 `clang ≥ 10` +전제: C++17 컴파일러, CMake ≥ 3.16, Python ≥ 3.10. +- Linux: `g++ ≥ 9` 또는 `clang ≥ 10`. CI 는 Ubuntu 22.04 에서 gcc-11 · clang-14 를 빌드한다 - Windows: Visual Studio 2019+ "C++ 데스크톱 개발"(MSVC) +- 빌드 자체는 CMake ≥ 3.16 이면 되지만, 정본 검증은 `CMakePresets.json` + (preset schema v3) 을 거치므로 **CMake ≥ 3.21** 이 필요하다 — 그보다 낮은 + CMake 는 빌드는 되고 프리셋만 못 읽는다 Python 패키지 (editable / 로컬 wheel 빌드): ```bash @@ -65,7 +84,13 @@ python -c "import vdsim; print('ok')" ```bash cmake -B build -DVDSIM_BUILD_PYTHON=ON # Linux 는 -G Ninja 추가 cmake --build build --config Release # Linux 는 -j -ctest --test-dir build -C Release # 328/328 ; 바이너리는 build/bin/ +cd build && ctest -C Release # 바이너리는 build/bin/ +``` +정본 테스트 개수는 [docs/VALIDATION.md](docs/VALIDATION.md) 한 곳에만 둔다. +재현은 고정된 프리셋으로 한다: +```bash +cmake --preset validation && cmake --build --preset validation +ctest --preset validation ``` ## Python 으로 실험하기 (제어기는 직접 작성) @@ -115,6 +140,206 @@ python3 gui/server.py --port 8100 # Windows: python gui\server.py --port 벡터, telemetry HUD. 키보드(↑↓ 가감속, ←→ 조향) 또는 게임패드-휠로 운전 — force feedback 은 `python tools/wheel_ffb_sdl.py --server --udp-port 8101`. +## 시각화 — 헤드리스 trace 렌더 + +한 번의 실행을 `.vdtrace` 파일 하나로 기록해 두고, 나중에 GUI·node/npm·브라우저 +없이 렌더할 수 있다. 기록은 opt-in 이라 켜지 않으면 동작하지 않는다. + +```python +plant.enable_trace("run.vdtrace", seed=0, run_id="demo") # 켜야만 기록 +... # 평소처럼 step() +path = plant.finalize_trace() +``` + +- `enable_trace(path, decimation=None, seed=None, run_id=None, producer=None, tags=None, role="plant")` — + `step()` 마다 한 샘플을 적분 *이전* 시점에서 취한다. 따라서 pose 는 시각 `t` 의 + 상태이고 `u_steer` / `u_fx` 는 `[t, t+control_dt)` 구간에 유지된 명령이다. + 반환값은 실제 적용된 decimation. +- `decimation=None` 이면 기록률이 100 Hz 이상으로 유지되는 최소 N 을 고른다 + (1 kHz 제어 → N=10, 20 Hz 루프는 N=1 로 손실 없음). +- `finalize_trace()` 는 채널을 flush 하고 manifest 를 확정한 뒤 파일을 닫으며, + 기록한 경로를 돌려준다(기록을 켠 적이 없으면 `None`). +- 컨테이너는 zip: `manifest.json` + `channels/*.f64` + `overlays/*.json`. + 이 파일 하나면 렌더가 되므로 재시뮬레이션도, 결과 파일 재파싱도 필요 없다. +- manifest 는 `schema_version` `"0.2"` 와 필수 필드 `role` 을 선언한다. `role` 은 + 검증 대상인 `"plant"` 또는 최적화·MPC 내부 예측 모델로 쓰인 `"predictor"` 다. + `VDSimPlant` 은 그 자체가 플랜트이므로 `enable_trace` 의 기본값은 `plant` 이고, + `vdsim_trace.TraceWriter` 를 직접 만드는 생산자는 `role=` 을 반드시 넘겨야 한다 + — 기본값이 없으므로 예측기 run 이 빠뜨림만으로 플랜트 근거가 되는 일이 없다. + 기존 `0.1` trace 도 그대로 읽힌다. `role` 이 없으면 경고 1회와 함께 `plant` 로 + 간주하고, `0.2` 에서 누락되면 에러다. 렌더러는 HUD 에 문자열로만 표시하고 + 이 값으로 화면 구성을 바꾸지 않는다. + +### 렌더 프리셋 + +프리셋은 trace 하나를 *어떤 화면으로* 그릴지만 정한다. trace 내용이 아니라 +렌더러 설정이므로 `.vdtrace` 안의 데이터는 바뀌지 않는다. 내장 프리셋은 +`overview` 하나다 — BEV, 주행 궤적, 선택적 기준 경로, 속도, 조향·종방향 명령. + +```bash +vdsim-render run.vdtrace # 기본값이 overview +vdsim-render run.vdtrace --list-presets +vdsim-render run.vdtrace --preset my.yaml # 사용자 프리셋(.yaml/.yml/.json) +vdsim-render run.vdtrace --panels speed,u_fx # CLI 가 프리셋을 이긴다 +``` + +- 우선순위는 **CLI 옵션 > 사용자 프리셋 파일 > 내장 기본값**. 사용자 프리셋은 + 바꿀 항목만 적으면 되고, 나머지는 `extends` 가 가리키는 내장 프리셋(기본 + `overview`)에서 상속된다. +- 프리셋이 선언하는 것은 패널 채널·라벨·y 범위·BEV 레이어 표시 여부다. + `speed` 는 `v_body` 에서 파생되고, 나머지 패널은 trace 채널 이름이다. +- trace 에 없는 채널의 패널은 조용히 생략된다 — 채널 일부만 기록한 run 에도 + 같은 프리셋을 쓸 수 있다. 생략을 에러로 되돌리려면 `required: true` 를 준다. +- 패널 위치와 BEV 축 좌표계는 고정이다. 데이터 autoscale 은 허용하지만 + 텍스트·범례·패널이 프레임을 벗어나면 실패이며, `render()` 가 + `layout_violations` 로 보고한다. +- `control`·`tire_limit` 은 계약만 되어 있고 미구현이며, `road_contact` 은 + 코어가 접촉 법선을 온전히 푸는 시점까지 예약어다. 이 이름들은 stub 을 두지 + 않고 거부한다. + +시나리오 지식은 실행이 끝난 뒤 **overlay** 로 붙인다. VDSim 은 `kind` / `name` +봉투만 검증하고 내용은 해석하지 않으므로, 새 생산자가 쓴 미지의 overlay 를 +현재 렌더러가 무시하고 지나갈 수 있다. + +```python +import vdsim_trace +vdsim_trace.attach_overlay(path, {"kind": "path2d", "name": "intended_path", + "xy": [[0.0, 0.0], [4.0, 0.0]]}) +vdsim_trace.attach_overlay(path, {"kind": "region", "name": "mu_patch", "mu": 0.35, + "polygon": [[40, -30], [60, -30], [60, 30], [40, 30]]}) +``` + +렌더러는 trace 하나를 입력받아 GIF 와 미리보기 PNG 를 쓴다(`[plot]` extra 의 +matplotlib + pillow 사용). + +```bash +vdsim-render run.vdtrace --out run.gif --fps 20 # 설치된 wheel +PYTHONPATH=python python3 -m vdsim_render run.vdtrace --out run.gif # 클론 트리 +``` + +옵션: `--png` 미리보기 경로 · `--stride` 프레임 간격 · `--fps` · `--dpi` · +`--title` · `--mp4`(`imageio-ffmpeg` 설치 시에만). 제어기 호라이즌용으로 +`--sidecar` / `--view-half` / `--preview-frame` 이 추가된다(아래 참고). + +BEV 화면에 그려지는 것: 기준 경로(점선, `path2d` overlay 가 있을 때만) · 주행 +궤적 · manifest `geometry` 로 크기를 잡은 차체 사각형 · 기록된 조향 명령만큼 +돌아간 앞바퀴 · 속도 화살표 · 바퀴별 마찰 이용률 색 · 화면 고정 좌표의 HUD · +현재 시각 커서가 붙은 `u_steer` / `u_fx` 시계열. 축 범위는 autoscale 이 아니라 +`geometry` 에서 계산하므로 프레임마다 스케일이 흔들리지 않는다. + +단일 run 화면에는 고정 좌표 계기 2개도 표시한다. 조향휠 다이얼은 기록된 로드휠 +조향각에 manifest `geometry.steer_ratio` 를 곱한 각도로 회전하고, 속도 게이지는 +km/h 를 표시한다. 속도 눈금은 trace 전체에서 한 번만 정해 20 km/h 단위로 올림하므로 +프레임마다 바늘의 의미가 바뀌지 않는다. matplotlib 헤드리스 출력에 포함되며 +node/npm/브라우저 의존성은 추가하지 않는다. + +기록 → overlay → 렌더 전 과정 예시: + +```bash +PYTHONPATH=build/python:python python3 examples/demo_grip_loss.py \ + --out demo.gif --keep-trace +``` + +### 여러 run 겹쳐 그리기 + +trace 를 2개 이상 넘기면 하나의 화면에 겹쳐 그린다 — 카메라 하나, 시계 하나, run +마다 다른 색, 주행 경로도 차량과 같은 색: + +```bash +vdsim-render base.vdtrace tuned.vdtrace wet.vdtrace --out compare.gif \ + --labels "base,tuned,wet" --alpha 0.5 +``` + +- 정렬 기준은 샘플 인덱스가 아니라 **시간**이다. 프레임 시각에서 각 run 을 보간하므로 + `dt` 가 다르거나 길이가 다른 trace 도 올바르게 겹친다. yaw 는 unwrap 후 보간하므로 + ±π 부근 헤딩이 반대로 도는 일이 없다. +- 먼저 끝난 run 은 마지막 자세를 유지하고 alpha 35 % 로 흐려지며, HUD 에 `(ended)` + 로 표시되고 경로도 더 이상 늘어나지 않는다. +- `--alpha`(차체 채움, 기본 0.55)와 `--path-alpha`(기본 0.9)로 겹친 차량이 얼마나 + 비쳐 보일지 조절한다. `--colors` 로 색을, `--labels` 로 범례를 지정한다(기본값은 + manifest 의 `run_id`, 없으면 파일 이름). +- 카메라: `--follow fit`(기본)은 모든 경로를 담는 고정 정사각 창, `--follow 1` 은 해당 + run 을 geometry 기반 창으로 추적한다. +- `--speed` 는 실시간 대비 재생 속도(`--stride` 는 단일 run 전용). 각 run 의 전체 경로가 + 옅게 깔리며 `--no-ghost` 로 끌 수 있다. +- 차체 색을 run 식별에 쓰므로 그립은 바퀴로 옮겼다 — utilization 이 0.8 을 넘으면 해당 + 타이어 외곽선이 빨간색이 된다. 오른쪽 패널은 모든 run 의 `u_steer` / `u_fx` 와 최대 + utilization 을 공통 시간축에 겹쳐 보여준다. +- 미리보기 PNG 는 임의 시점이 아니라 run 들이 **가장 크게 벌어진** 프레임에서 뽑는다. + +전 과정 — 같은 조작을 마찰계수 3단계로 기록해 3개의 trace 를 겹쳐 그리기: + +```bash +PYTHONPATH=build/python:python python3 examples/demo_compare_runs.py --out compare.gif +``` + +### 제어기 호라이즌 — waypoint / target / prediction + +"reference" 라는 한 단어로 뭉뚱그려지곤 하는 세 곡선은 서로 다른 것이다. 렌더러는 +코드·사이드카 키·범례에서 이름을 구분해서 쓴다. + +- **waypoint** — 최종적으로 따라가야 할 전역 경로. 시간 불변, 시나리오당 1개이며 + `path2d` overlay 로 들어와 회색 점선으로 한 번만 그려진다. overlay `name` 은 + `waypoint` / `reference_path` / `ref_path` 를 모두 받는다(앞의 것이 권장, + 나머지는 옛 trace 호환용). +- **target (MPC input)** — *이번* 스텝에 제어기로 들어가는 레퍼런스 호라이즌 + (N+1 점). 시간 가변이며 점 간격이 계획 속도를 따르므로 감속 구간에서 압축된다. + 간격이 보이도록 마커가 있는 파란 선. +- **prediction (MPC output)** — 제어기가 이번 스텝에 풀어낸 호라이즌(N+1 점). + 시간 가변이고, 실패한 스텝에서는 솔버가 돌려준 값 그대로다. 주황색이며 솔버 + status 가 0이 아닌 동안 굵은 빨강으로 바뀐다. + +`target` / `prediction` 은 `(K, N+1, 2)` 배열이고 trace 컨테이너의 채널 표는 +스칼라·고정폭 행의 화이트리스트라, 컨테이너 안이 아니라 trace 옆의 **사이드카** +`.npz` 로 실어 나른다. + +``` +run.vdtrace # trace 본체, 이것만으로도 렌더된다 +run.qp.npz # 선택적 호라이즌 사이드카, 있으면 자동으로 붙는다 +``` + +| 키 | 모양 | 의미 | +| --- | --- | --- | +| `t` | (K,) | 스텝 시각 [s] — 인덱스가 아니라 최근접 시간으로 trace 샘플과 맞춘다 | +| `tgt_XY` | (K, N+1, 2) | target 호라이즌, world 좌표 [m] | +| `pred_XY` | (K, N+1, 2) | prediction 호라이즌, world 좌표 [m] | +| `status` | (K,) | 솔버 status, `0` = 성공 | +| `solve_ms` | (K,) | solve 시간 [ms] — 선택, HUD 에 표시 | +| `tgt_v` | (K, N+1) | target 을 따라가는 계획 속도 [m/s] — 선택 | +| `ego_XY` | (K, 2) | 스텝별 후륜축 위치 — 선택, 화면 범위 산출에 쓰임 | + +필수는 앞의 4개뿐이고 사이드카 자체가 선택이다. 없으면 렌더러는 이전과 완전히 +동일하게 동작하며 호라이즌 artist 를 아예 만들지 않는다. + +- 호라이즌은 **world** 좌표로 저장할 것. ego / Frenet 프레임에서 푸는 제어기는 + 역변환을 먼저 적용해야 한다. 검수 기준은 `pred_XY[k, 0]` 이 그 스텝의 차량 + 후륜축 위치와 0.1 m 이내로 일치하는지다. 어긋나면 프레임이 틀린 것이다. +- 실패한 스텝은 실패한 그대로 기록할 것. 직전 값으로 덮어쓰지 말고 그 스텝의 + `pred_XY` 를 NaN 이면 NaN 째로 남긴다. 렌더러가 비유한값을 건너뛰며, 덮어쓰면 + 영상으로 보여주려던 실패 자체가 사라진다. + +```bash +vdsim-render run.vdtrace --out run.gif --view-half 100 --preview-frame first-fail +``` + +- `--sidecar` 로 `.npz` 를 직접 지정한다. 기본값은 trace 경로의 확장자를 `.qp.npz` + 로 바꾼 경로(`run.vdtrace` -> `run.qp.npz`)가 있으면 그것, 없으면 사이드카 + 없음이다. 플래그를 명시하면 파일이 없을 때 조용히 넘어가지 않고 오류가 난다. +- `--view-half` 는 BEV 반창 크기 [m]. 기본 추적창은 축거에서 유도되어(축거 약 6배) + MPC 호라이즌보다 훨씬 짧으므로, 100 m 짜리 호라이즌은 창을 넓히지 않으면 화면 + 밖으로 나간다. +- `--preview-frame util-peak`(기본, 기존 동작 유지)은 마찰 이용률 피크에서 미리보기 + PNG 를 뽑고, `first-fail` 은 첫 실패 스텝에서 뽑는다. 실패가 한 번도 없으면 + 안내를 출력하고 util-peak 으로 되돌아간다. +- 실패가 하나라도 있는 run 은 `--preview-frame` 값과 무관하게 GIF 옆에 + `_firstfail.png` 를 함께 쓰고, 실패가 이어지는 동안 BEV 에 + `QP FAIL (status=N)` 배너를 띄운다. +- 실패 시각은 커맨드 패널에 세로 구간으로 표시되며, 연속 실패 스텝은 하나로 병합된다. + 생산자가 `qp_fail` 이라는 이름의 `event` overlay 를 붙였으면 그것을, 없으면 + 사이드카의 `status` 배열을 쓴다. + +세 옵션 모두 단일 run 전용이라 겹침 모드에서는 무시된다. + ## 설정 — parts catalog & scene (v0.3) 차량 = `configs/parts/` 조합 **blueprint**, 실행 = `fleet[]`가 있는 **scene** diff --git a/README.md b/README.md index cb98820d..7f0aa418 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,23 @@ peak (drift>1) — real grip loss, not a soft-clamp.* Reproduce: `python examples/demo_grip_loss.py` (`pip install vdsim[plot]` or local wheel). -Open-core vehicle-dynamics simulator. VDSim owns the **vehicle** — validated +> **Experimental / pre-release software — not for production use.** +> What is verified, and what is *not yet*: see [docs/VALIDATION.md](docs/VALIDATION.md). + +**Verification scope.** VDSim's tire-force layer is cross-checked against two independent +implementations driven by the same `.tir` parameter file: CarMaker MF-Tyre/MF-Swift +(pure longitudinal 0.00%, pure lateral 0.09%) and Chrono Pac02 (BSD-3, ~0.8% / 0.7%). +This is a pure-slip cross-check between implementations, not product parity. +Full-vehicle behaviour (suspension, transient) and comparison against real-vehicle +measurements are **not yet** covered. + +![Grip-loss demo](docs/assets/demo_grip_loss.gif) + +*Deterministic VDSim plant: controller hits an unseen low-μ patch, tyres saturate past +peak (drift>1) — real grip loss, not a soft-clamp.* Reproduce: +`python examples/demo_grip_loss.py` (`pip install vdsim[plot]` or local wheel). + +Open-core vehicle-dynamics simulator. VDSim owns the **vehicle** — implemented L1–L5 dynamics with real Pacejka MF / LuGre / belt-transient tires, hardpoint suspension kinematics you can design against, and bidirectional FMI 2.0 — and delegates rendering/sensors to tools like CARLA. It is the chassis-accurate half @@ -54,9 +70,12 @@ Source for the script: [`examples/quickstart.py`](examples/quickstart.py) — us ## From source -Prerequisites: a C++17 compiler, CMake ≥ 3.20, Python ≥ 3.10. -- Linux: `g++ ≥ 9` or `clang ≥ 10`. +Prerequisites: a C++17 compiler, CMake ≥ 3.16, Python ≥ 3.10. +- Linux: `g++ ≥ 9` or `clang ≥ 10`. CI builds gcc-11 and clang-14 on Ubuntu 22.04. - Windows: Visual Studio 2019+ with "Desktop development with C++" (MSVC). +- Building the tree needs CMake ≥ 3.16, but the canonical validation run goes + through `CMakePresets.json` (preset schema v3), which needs **CMake ≥ 3.21** — + an older CMake builds VDSim fine and simply cannot read the preset. Python package (editable / local wheel build): ```bash @@ -68,7 +87,13 @@ Full C++ tree (tests, real-time runtime, FMI, CARLA bridge): ```bash cmake -B build -DVDSIM_BUILD_PYTHON=ON # add -G Ninja on Linux cmake --build build --config Release # -j on Linux -ctest --test-dir build -C Release # 328/328 ; binaries in build/bin/ +cd build && ctest -C Release # binaries in build/bin/ +``` +The canonical suite count lives in one place only — [docs/VALIDATION.md](docs/VALIDATION.md). +Reproduce it with the pinned preset: +```bash +cmake --preset validation && cmake --build --preset validation +ctest --preset validation ``` ## Run an experiment in Python (write your own controller) @@ -120,6 +145,233 @@ slip (κ, α) / tire-force vectors, and a telemetry HUD. Drive with the keyboard (↑↓ throttle/brake, ←→ steer) or a gamepad-wheel — force feedback via `python tools/wheel_ffb_sdl.py --server --udp-port 8101`. +## Visualization — headless trace render + +A run can be recorded to a single `.vdtrace` file and rendered later, with no GUI, +no node/npm and no browser. Recording is opt-in and off unless enabled: + +```python +plant.enable_trace("run.vdtrace", seed=0, run_id="demo") # off unless called +... # step() as usual +path = plant.finalize_trace() +``` + +- `enable_trace(path, decimation=None, seed=None, run_id=None, producer=None, tags=None, role="plant")` — + one sample is offered per `step()`, taken *before* the step is integrated, so the + pose is the state at `t` and `u_steer` / `u_fx` are the command held over + `[t, t+control_dt)`. Returns the resolved decimation. +- `decimation=None` picks the smallest N whose record rate stays at or above 100 Hz + (1 kHz control → N=10; a 20 Hz loop stays at N=1 and loses nothing). +- `finalize_trace()` flushes channels, freezes the manifest and closes the file; + it returns the written path (`None` when recording was never enabled). +- The container is a zip: `manifest.json` + `channels/*.f64` + `overlays/*.json`. + That one file is enough to render — no re-simulation, no results file. +- The manifest declares `schema_version` `"0.2"` and a required `role`, either + `"plant"` (the simulator under verification) or `"predictor"` (the same code + driven as an optimiser's internal model). `enable_trace` defaults to `plant` + because `VDSimPlant` *is* the plant; a producer that builds a + `vdsim_trace.TraceWriter` directly must pass `role=` — there is no default, + so a predictor run cannot be recorded as plant evidence by omission. + Reading a legacy `0.1` trace still works: a missing `role` resolves to + `plant` with one warning. A `0.2` trace without one is an error. + The renderer prints the role in the HUD and never branches on it. + +### Render presets + +A preset decides *which view* of one trace is drawn. It is renderer +configuration, not trace content, so presets never change what a `.vdtrace` +holds. `overview` is the built-in — BEV, driven path, optional reference path, +speed, steer and longitudinal command: + +```bash +vdsim-render run.vdtrace # overview is the default +vdsim-render run.vdtrace --list-presets +vdsim-render run.vdtrace --preset my.yaml # user preset (.yaml/.yml/.json) +vdsim-render run.vdtrace --panels speed,u_fx # CLI wins over the preset +``` + +- Resolution order: **CLI option > user preset file > built-in default**. A user + preset only has to state what it changes; the rest is inherited from the + built-in named by `extends` (default `overview`). +- A preset declares panel channels, labels, y-ranges and BEV layer visibility. + `speed` is derived from `v_body`; every other panel names a trace channel. +- A channel the trace does not carry drops its panel silently — the same preset + works on a run recorded with a channel subset. Mark a panel `required: true` + to turn that omission back into an error. +- Panel positions and the BEV axis coordinate system are fixed. Data autoscale + is allowed; text, legend or panels leaving the frame is a failure and + `render()` reports it in `layout_violations`. +- `control` and `tire_limit` are contracted but not implemented, and + `road_contact` is reserved until the core resolves full contact normals. + Those names are refused rather than stubbed. + +```yaml +# my.yaml — everything not named here is inherited from overview +name: steer_only +panels: + - channel: u_steer + label: delta + ylim: [-0.5, 0.5] +bev: + waypoint: false + view_half_m: 45.0 +``` + +Scenario knowledge is attached after the run as **overlays**. VDSim validates the +`kind` / `name` envelope and stores the object without interpreting it, so a newer +producer can write overlays that today's renderer ignores: + +```python +import vdsim_trace +vdsim_trace.attach_overlay(path, {"kind": "path2d", "name": "intended_path", + "xy": [[0.0, 0.0], [4.0, 0.0]]}) +vdsim_trace.attach_overlay(path, {"kind": "region", "name": "mu_patch", "mu": 0.35, + "polygon": [[40, -30], [60, -30], [60, 30], [40, 30]]}) +``` + +The renderer takes one trace and writes a GIF plus a preview PNG (matplotlib + +pillow, from the `[plot]` extra): + +```bash +vdsim-render run.vdtrace --out run.gif --fps 20 # installed wheel +PYTHONPATH=python python3 -m vdsim_render run.vdtrace --out run.gif # cloned tree +``` + +Options: `--png` preview path · `--stride` frame stride · `--fps` · `--dpi` · +`--title` · `--mp4` (only when `imageio-ffmpeg` is installed). Controller +horizons add `--sidecar` / `--view-half` / `--preview-frame` (below). + +The bird's-eye frame shows the reference path (dashed, from a `path2d` overlay; +omitted when absent), the driven path, a body rectangle sized from manifest +`geometry`, front wheels turned by the recorded steering command, a velocity +arrow, per-wheel friction-utilization colour, a HUD in fixed screen coordinates, +and the `u_steer` / `u_fx` time series with a current-time cursor. Axis limits +come from `geometry`, never from autoscale, so the scale never drifts between +frames. + +The single-run view also carries two fixed-screen instruments: a steering-wheel +dial whose rotation is the recorded road-wheel angle multiplied by manifest +`geometry.steer_ratio`, and a speedometer in km/h. The speedometer scale is fixed +for the whole trace (rounded up in 20 km/h steps), so neither needle changes +meaning between frames. These instruments are part of the headless matplotlib +output; they do not add node/npm/browser dependencies. + +End to end — record, attach overlays, render: + +```bash +PYTHONPATH=build/python:python python3 examples/demo_grip_loss.py \ + --out demo.gif --keep-trace +``` + +### Overlaying several runs + +Pass two or more traces and the renderer draws them in one view — one camera, one +clock, a colour per run and the driven path in that same colour: + +```bash +vdsim-render base.vdtrace tuned.vdtrace wet.vdtrace --out compare.gif \ + --labels "base,tuned,wet" --alpha 0.5 +``` + +- Runs are aligned by **time**, not by sample index: every run is interpolated at + the frame time, so traces recorded at different `dt`, or of different length, + still overlay correctly. Yaw is unwrapped first, so a heading near ±π does not + swing the long way round. +- A run whose trace ends early holds its final pose, fades to 35 % alpha, is + marked `(ended)` in the HUD, and stops extending its path. +- `--alpha` (body fill, default 0.55) and `--path-alpha` (default 0.9) set how + much of an overlapped vehicle shows through. `--colors` overrides the palette, + `--labels` the legend (default: manifest `run_id`, else the file stem). +- Camera: `--follow fit` (default) is a static square window holding every route; + `--follow 1` tracks that run with the usual geometry-derived window. +- `--speed` sets playback rate relative to wall-clock (`--stride` is single-run + only). Each run's whole route is drawn faintly underneath; `--no-ghost` removes it. +- Body colour is spent on run identity, so grip moves to the wheels: a tyre + outline turns red once its utilization crosses 0.8. The right-hand panels + overlay every run's `u_steer` / `u_fx` and max utilization on one shared time axis. +- The preview PNG is taken at the frame of **maximum divergence** between runs, + not at an arbitrary time. + +End to end — the same manoeuvre at three friction levels, recorded to three +traces and overlaid from the files alone: + +```bash +PYTHONPATH=build/python:python python3 examples/demo_compare_runs.py --out compare.gif +``` + +### Controller horizons — waypoint / target / prediction + +Three curves get routinely conflated under the word "reference". They are +different objects, and the renderer keeps them apart by name — in the code, in +the sidecar keys and in the legend: + +- **waypoint** — the global route the run has to follow. Time-invariant, one per + scenario, carried by a `path2d` overlay and drawn once as a dashed grey line. + The overlay `name` may be `waypoint`, `reference_path` or `ref_path`; the + first is preferred, the other two stay accepted for older traces. +- **target (MPC input)** — the reference horizon handed to the controller at + *this* step (N+1 points). Time-varying: the point spacing follows the planned + speed, so it compresses under braking. Blue with markers, so the spacing is + readable. +- **prediction (MPC output)** — the horizon the controller solved for at this + step (N+1 points). Time-varying; on a failed solve it is whatever the solver + returned. Orange, switching to thick red while the solver status is non-zero. + +`target` and `prediction` are `(K, N+1, 2)` arrays and the trace container's +channel table is a whitelist of scalar / fixed-width rows, so they travel in a +**sidecar** `.npz` beside the trace rather than inside the container: + +``` +run.vdtrace # the trace, renderable on its own +run.qp.npz # optional horizons sidecar, picked up automatically +``` + +| key | shape | meaning | +| --- | --- | --- | +| `t` | (K,) | step time [s] — matched to trace samples by nearest time, not by index | +| `tgt_XY` | (K, N+1, 2) | target horizon, world frame [m] | +| `pred_XY` | (K, N+1, 2) | prediction horizon, world frame [m] | +| `status` | (K,) | solver status, `0` = success | +| `solve_ms` | (K,) | solve time [ms] — optional, shown in the HUD | +| `tgt_v` | (K, N+1) | planned speed along the target [m/s] — optional | +| `ego_XY` | (K, 2) | rear-axle position per step — optional, used to fit the view | + +Only the first four keys are required, and the sidecar as a whole is optional: +without one the renderer behaves exactly as it did before, and the two horizon +artists are never created. + +- Write the horizons in the **world** frame. A controller solving in an ego or + Frenet frame has to undo that transform first. The check is that + `pred_XY[k, 0]` lands within ~0.1 m of the vehicle's rear-axle position at + step `k` — if it does not, the frame is wrong. +- Record a failed solve as it happened: keep that step's `pred_XY`, NaN + included, instead of holding the previous one. The renderer skips non-finite + points; overwriting them hides the failure the video is meant to show. + +```bash +vdsim-render run.vdtrace --out run.gif --view-half 100 --preview-frame first-fail +``` + +- `--sidecar` names the `.npz` explicitly. The default is the trace path with + its suffix swapped for `.qp.npz` (`run.vdtrace` -> `run.qp.npz`) when that + file exists, and no sidecar otherwise; passing the flag turns a missing file + into an error instead of a silent skip. +- `--view-half` sets the bird's-eye half-window [m]. The default window is + derived from the wheelbase (~6 wheelbases) and is far shorter than an MPC + horizon, so a 100 m horizon leaves the frame unless the window is widened. +- `--preview-frame util-peak` (default, unchanged) puts the preview PNG at the + friction-utilization peak; `first-fail` puts it at the first non-zero solver + status, printing a note and falling back to the peak when the run never + failed. +- A run containing any failed solve also gets `_firstfail.png` written next + to the GIF, whatever `--preview-frame` says, and the BEV carries a + `QP FAIL (status=N)` banner for as long as the status is non-zero. +- Failure times are marked on the command panel as vertical spans, merged over + consecutive failing steps. They come from an `event` overlay named `qp_fail` + when the producer attached one, and from the sidecar `status` array otherwise. + +All three flags are single-run only — overlay mode ignores them. + ## Config — parts catalog & scenes (v0.3) Vehicles are **blueprints** over `configs/parts/` (chassis, tire, drivetrain, …). diff --git a/configs/comms/json_nmea.yaml b/configs/comms/json_nmea.yaml new file mode 100644 index 00000000..35dfc794 --- /dev/null +++ b/configs/comms/json_nmea.yaml @@ -0,0 +1,37 @@ +# Text TX templates — works today in vdsim_realtime (the C++ comms router). +# Scenario-level: a scene references this via `comms: json_nmea`. +# TX json: vehicle 0 STATE as a compact one-line JSON object (HTTP/MQTT bridges). +# Non-finite values (diverged solver) are emitted as `null`, so the +# object always parses; it never puts "nan"/"inf" on the wire. +# TX nmea_gga: vehicle 0 as an NMEA 0183 $GPGGA sentence ("*HH\r\n"-terminated). +# A non-finite or absurd state degrades to a "no fix" sentence +# (quality 0, empty position fields) instead of a malformed one. +# RX: canonical VDS1 CMD fan-in, same as configs/comms/vds1_loopback.yaml. +# +# `origin` is the geodetic datum the sim's ENU metres (X east, Y north, Z up) are +# referenced to; it only matters for lat/lon templates. Optional — it defaults to +# lat 0 / lon 0 / alt 0. The ENU -> lat/lon transform is the exact closed-form +# WGS84 inverse (ENU -> ECEF -> geodetic; formula + limits: cosim/comms_templates.hpp, +# enu_to_geodetic), accurate to sub-micrometre at any latitude and any offset — +# it is NOT latitude-sensitive the way an equirectangular approximation is. +# What remains is the definition of ENU itself: it is a flat tangent plane, so a +# horizontal offset d sits ~d^2/(2R) above the ellipsoid (7.8 m at 10 km, and that +# shows up in the GGA altitude) and its ground track differs from a true WGS84 +# geodesic by ~d^3/(3R^2) (8 mm at 10 km, 8.2 m at 100 km). Altitudes are +# ellipsoidal, not orthometric: there is no geoid model, so geoid separation is 0. +# The GGA fix follows the *measured* GNSS channel, so configured sensor noise shows up. +# +# Exercised end-to-end by tests/scripts/test_comms_tx_templates.py, which runs +# THIS file (only the ports are remapped onto free ones). +name: json_nmea +channels: + - source: 0.state + template: json + to: [ {ip: 127.0.0.1, port: 7100} ] + - source: 0.state + template: nmea_gga + origin: { lat: 37.5665, lon: 126.9780, alt: 38.0 } # Seoul City Hall + to: [ {ip: 127.0.0.1, port: 9001} ] + - direction: in + template: vds1_cmd + listen: { port: 7001 } diff --git a/configs/comms/vds1_loopback.yaml b/configs/comms/vds1_loopback.yaml index 8f60a74a..8b02c74f 100644 --- a/configs/comms/vds1_loopback.yaml +++ b/configs/comms/vds1_loopback.yaml @@ -3,8 +3,8 @@ # TX: vehicle 0 STATE fans out to a controller + a logger (canonical VDS1 binary). # RX: any controller sends a VDS1 CMD here; the packet's vehicle_id header # selects which agent's control it drives (fan-in). -# (json / nmea_gga templates and sensor.* sources are specced but not yet -# realized by the C++ router; see docs/CONFIG_GUIDE.md §3.) +# (json / nmea_gga TX templates are realized too — see configs/comms/json_nmea.yaml. +# sensor.* sources are still specced only; see docs/CONFIG_GUIDE.md §3.) name: vds1_loopback channels: - source: 0.state diff --git a/configs/scenes/two_vehicle_race.yaml b/configs/scenes/two_vehicle_race.yaml index 82087c30..083e6cdf 100644 --- a/configs/scenes/two_vehicle_race.yaml +++ b/configs/scenes/two_vehicle_race.yaml @@ -12,6 +12,20 @@ fleet: y0: -1.5 yaw0: 0 vx0: 12 + # Worked example of the scene sensor declaration (CONFIG_GUIDE §2.3). mount is + # the body-frame pose (pos [m] x fwd / y left / z up, rpy [rad]); it is parsed + # into WorldScenario and stored for future render coupling — it does not affect + # the simulation today. No noise_std here on purpose: this scene stays + # bit-identical to ground truth so the comms/GUI tests that read vehicle 0 keep + # their exact expectations. Add noise_std/bias/bias_rw per entry to degrade it. + # A mount-only block like this one declares mounts and nothing else: it does not + # override a scenario-level `sensors:` file (CONFIG_GUIDE §2.3.1). + sensors: + - { id: gnss_roof, type: gnss, mount: { pos: [0.20, 0.0, 1.42], rpy: [0, 0, 0] }, rate: 10 } + - { id: imu_cg, type: imu, mount: { pos: [1.35, 0.0, 0.45], rpy: [0, 0, 0] }, rate: 200 } + - { id: enc, type: wheel_speed, mount: { pos: [0.0, 0.0, 0.32], rpy: [0, 0, 0] }, rate: 100 } + - { id: cam_front, type: camera, mount: { pos: [1.60, 0.0, 1.20], rpy: [0, -0.05, 0] }, rate: 30, + params: { fov_deg: 90 } } - id: 1 blueprint: vehicle.sports_aggressive level: L2 diff --git a/core/include/vdsim/contact_frame.hpp b/core/include/vdsim/contact_frame.hpp new file mode 100644 index 00000000..0dec3fda --- /dev/null +++ b/core/include/vdsim/contact_frame.hpp @@ -0,0 +1,101 @@ +#pragma once + +#include "vdsim/types.hpp" + +#include + +namespace vdsim { + +/** + * Orthonormal tire contact frame expressed in an arbitrary parent frame. + * + * The caller supplies the injected road normal and wheel-forward direction in + * the same frame. The construction is exact for finite bank angles: + * `z_c = unit(normal)`, `x_c = unit(x_w - z_c*(x_w dot z_c))`, and + * `y_c = z_c cross x_c`. No small-angle approximation or planar fallback is + * used. A degenerate input produces `valid=false` and must be treated as no + * contact by the dynamics caller. + */ +struct ContactFrame { + Vec3 x_c {Vec3::Zero()}; + Vec3 y_c {Vec3::Zero()}; + Vec3 z_c {Vec3::Zero()}; + bool valid {false}; + + /** Resolve a parent-frame velocity into longitudinal, lateral, normal components. */ + Vec3 resolve_velocity(const Vec3& parent_velocity) const noexcept { + return Vec3(parent_velocity.dot(x_c), + parent_velocity.dot(y_c), + parent_velocity.dot(z_c)); + } + + /** Rotate a contact-frame tangential force into the parent frame. */ + Vec3 tangential_force(double Fx, double Fy) const noexcept { + return Vec3(Fx * x_c.x() + Fy * y_c.x(), + Fx * x_c.y() + Fy * y_c.y(), + Fx * x_c.z() + Fy * y_c.z()); + } + + /** Return the parent-frame vector for normal-load magnitude Fz. */ + Vec3 normal_force(double Fz) const noexcept { return Fz * z_c; } + + /** Rotate the complete contact wrench force into the parent frame. */ + Vec3 contact_force(double Fx, double Fy, double Fz) const noexcept { + return tangential_force(Fx, Fy) + normal_force(Fz); + } + + /** Return the moment about a parent-frame origin from the complete force. */ + Vec3 contact_moment(const Vec3& lever_arm, + double Fx, + double Fy, + double Fz) const noexcept { + return lever_arm.cross(contact_force(Fx, Fy, Fz)); + } +}; + +/** + * Build the right-handed tire contact frame without a flat-road fallback. + * + * The exact canonical-flat branch preserves the established bit-level L3/L4 + * path for `normal=(0,0,1)` while representing the same analytic orthonormal + * basis generated by wheel-heading sine and cosine. + */ +inline ContactFrame make_contact_frame(const Vec3& injected_normal, + const Vec3& wheel_forward) noexcept { + ContactFrame frame; + if (!injected_normal.allFinite() || !wheel_forward.allFinite()) return frame; + const double normal_norm = injected_normal.norm(); + if (!(normal_norm > 1e-12)) return frame; + + if (injected_normal.x() == 0.0 && injected_normal.y() == 0.0 + && injected_normal.z() > 0.0 && wheel_forward.z() == 0.0) { + const double forward_norm = std::hypot(wheel_forward.x(), wheel_forward.y()); + if (!(forward_norm > 1e-12)) return frame; + frame.x_c = wheel_forward / forward_norm; + frame.y_c = Vec3(-frame.x_c.y(), frame.x_c.x(), 0.0); + frame.z_c = Vec3::UnitZ(); + frame.valid = true; + return frame; + } + + frame.z_c = injected_normal / normal_norm; + const Vec3 projected = wheel_forward + - frame.z_c * wheel_forward.dot(frame.z_c); + const double projected_norm = projected.norm(); + if (!(projected_norm > 1e-12)) { + frame = ContactFrame{}; + return frame; + } + frame.x_c = projected / projected_norm; + frame.y_c = frame.z_c.cross(frame.x_c); + const double lateral_norm = frame.y_c.norm(); + if (!(lateral_norm > 1e-12) || !frame.y_c.allFinite()) { + frame = ContactFrame{}; + return frame; + } + frame.y_c /= lateral_norm; + frame.valid = true; + return frame; +} + +} // namespace vdsim diff --git a/core/include/vdsim/interfaces.hpp b/core/include/vdsim/interfaces.hpp index 550c656d..5b95abca 100644 --- a/core/include/vdsim/interfaces.hpp +++ b/core/include/vdsim/interfaces.hpp @@ -74,7 +74,15 @@ class IVehicleDynamics { virtual const State& state() const noexcept = 0; // Per-wheel diagnostics (for validation/debug) - virtual std::array tire_forces_body() const = 0; // [N], body frame + /** + * Tangential tire force in the body frame [N]. + * + * The vector contains only contact-plane Fx/Fy rotated into body axes; its + * body-z component may therefore be non-zero on a bank. It deliberately + * excludes the normal load Fz. Dynamics consumers use an internal full + * contact wrench, preserving this method's established ABI and meaning. + */ + virtual std::array tire_forces_body() const = 0; // Per-wheel tire force in the WHEEL (tire) frame: x = wheel heading // (longitudinal), y = wheel lateral. The body-frame force is this rotated by // the wheel's steer angle. Default returns the body-frame force (levels with no diff --git a/core/include/vdsim/params.hpp b/core/include/vdsim/params.hpp index 6a92f2e4..5fa8860a 100644 --- a/core/include/vdsim/params.hpp +++ b/core/include/vdsim/params.hpp @@ -53,7 +53,9 @@ struct VehicleParams { enum class Drive { FWD, RWD, AWD }; enum class Differential { Open, Locked, LSD }; Drive drive_type {Drive::RWD}; + // AWD longitudinal split: front-axle fraction of Fx_total torque (0..1). double drive_split_front {0.5}; + // VLA plant path: skip the vehicle kinematic low-speed blend (rely on tyre VLOW). bool plant_path {false}; Differential differential {Differential::Open}; double lsd_preload {0.10}; // [-] 0..0.5, baseline bias magnitude diff --git a/core/src/contact_frame_internal.hpp b/core/src/contact_frame_internal.hpp new file mode 100644 index 00000000..aff3fa95 --- /dev/null +++ b/core/src/contact_frame_internal.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "vdsim/types.hpp" + +#include + +namespace vdsim { + +class IVehicleDynamics; + +namespace detail { + +/** Internal L4 spatial contact kinematics; not part of the public plant API. */ +struct L4ContactKinematics { + bool enabled {false}; + Quat body_to_world {Quat::Identity()}; + Vec3 angular_velocity_body {Vec3::Zero()}; + std::array wheel_vertical_velocity_world {{0.0, 0.0, 0.0, 0.0}}; +}; + +/** Internal per-wheel contact load; public tire-force diagnostics stay tangential-only. */ +struct ContactLoad { + Vec3 full_force_body {Vec3::Zero()}; + Vec3 applied_force_body {Vec3::Zero()}; + Vec3 full_force_world {Vec3::Zero()}; + Vec3 applied_force_world {Vec3::Zero()}; + Vec3 moment_delta_body {Vec3::Zero()}; +}; + +/** Inject one-step L4 spatial context into the shared SevenDOF core. */ +bool set_l4_contact_kinematics(IVehicleDynamics& dynamics, + const L4ContactKinematics& context) noexcept; + +/** Return full loads and non-flat increments without extending the public plant API. */ +std::array contact_loads( + const IVehicleDynamics& dynamics) noexcept; + +} // namespace detail +} // namespace vdsim diff --git a/core/src/fourteen_dof_dynamics.cpp b/core/src/fourteen_dof_dynamics.cpp index 6d786172..083c9064 100644 --- a/core/src/fourteen_dof_dynamics.cpp +++ b/core/src/fourteen_dof_dynamics.cpp @@ -44,6 +44,8 @@ #include "vdsim/subsystems.hpp" #include "vdsim/suspension.hpp" +#include "contact_frame_internal.hpp" + #include #include @@ -121,6 +123,9 @@ class FourteenDOFDynamics : public IVehicleDynamics { th_ = 0.0; th_dot_ = 0.0; z_u_ = {{0.0, 0.0, 0.0, 0.0}}; z_u_dot_ = {{0.0, 0.0, 0.0, 0.0}}; + contact_loads_.fill(detail::ContactLoad{}); + contact_force_delta_world_.fill(Vec3::Zero()); + contact_full_force_world_.fill(Vec3::Zero()); if (!suspension_) suspension_ = make_default_suspension(vp_); if (!arb_front_) arb_front_ = make_default_antirollbar(vp_, 0); if (!arb_rear_) arb_rear_ = make_default_antirollbar(vp_, 1); @@ -150,6 +155,10 @@ class FourteenDOFDynamics : public IVehicleDynamics { continue; const double z_corner_s = z_s_ + ry_[i] * std::sin(phi_) - rx_[i] * std::sin(th_); + // z_u is driven by the per-wheel contact road_dz in the vertical + // EOM below. Therefore contact-height differences enter the + // existing travel -> camber/toe sweep table through this single + // physical suspension coordinate (no layer-specific correction). const double wheel_travel = z_u_[i] - z_corner_s; ISuspensionKinematics* k = (i < 2) ? kine_front_.get() : kine_rear_.get(); @@ -179,6 +188,8 @@ class FourteenDOFDynamics : public IVehicleDynamics { const double k_tire = std::max(1.0, ts_.for_wheel(WHEEL_FL).tire_vertical_stiffness); const double Lwb = vp_.wheelbase; const double vx = state_.velocity.x(); + const double yaw = yaw_from_quat(state_.orientation); + const double yaw_rate = state_.angular_velocity.z(); const double q_aero = 0.5 * kAirDensity * vp_.frontal_area * vx * std::abs(vx); const double aero_f = 0.5 * vp_.aero_lift_front * q_aero; const double aero_r = 0.5 * vp_.aero_lift_rear * q_aero; @@ -186,33 +197,74 @@ class FourteenDOFDynamics : public IVehicleDynamics { const double st_r = vp_.mass * kGravity * vp_.cg_to_front / (2.0 * Lwb); std::array fz_dyn; for (int i = 0; i < NUM_WHEELS; ++i) { - const double cos_slope = std::max(0.1, contacts[i].normal.z()); - const double st = ((i < 2) ? st_f : st_r) * cos_slope + if (!contacts[i].is_valid) { + fz_dyn[i] = 0.0; + continue; + } + const double normal_norm = contacts[i].normal.norm(); + if (!contacts[i].normal.allFinite() || normal_norm <= 1e-12) { + fz_dyn[i] = 0.0; + continue; + } + const Vec3 normal = contacts[i].normal / normal_norm; + const double normal_y_body = -std::sin(yaw) * normal.x() + + std::cos(yaw) * normal.y(); + // Exact normal-force balance for a banked steady turn: + // N/m = g*n_z + (v_x*r)*n_y(body). The second term is the + // centripetal acceleration projected on the injected normal; + // it is applied here in the load path, never as a postprocessed + // equivalent lateral-acceleration offset. + const double normal_accel = std::max( + 0.0, kGravity * std::max(0.0, normal.z()) + + vx * yaw_rate * normal_y_body); + const double st = ((i < 2) ? st_f : st_r) + * (normal_accel / kGravity) + ((i < 2) ? aero_f : aero_r); fz_dyn[i] = std::max(0.0, st + k_tire * (contacts[i].road_dz - z_u_[i])); } inner_->set_external_fz(fz_dyn); } + detail::L4ContactKinematics spatial_contact; + if (level() == Level::L4_Kinematic) { + const double yaw = yaw_from_quat(state_.orientation); + spatial_contact.enabled = true; + spatial_contact.body_to_world = quat_from_euler({phi_, th_, yaw}); + spatial_contact.angular_velocity_body = + Vec3(phi_dot_, th_dot_, state_.angular_velocity.z()); + spatial_contact.wheel_vertical_velocity_world = z_u_dot_; + } + detail::set_l4_contact_kinematics(*inner_, spatial_contact); inner_->step(u, contacts, dt); + contact_loads_ = detail::contact_loads(*inner_); + for (int i = 0; i < NUM_WHEELS; ++i) { + contact_force_delta_world_[i] = + contact_loads_[i].applied_force_world; + contact_full_force_world_[i] = + contact_loads_[i].full_force_world; + } state_ = inner_->state(); mx_ = inner_->wheel_overturning_moment(); // camber contact-migration overturning if (dt > 0.0 && (mb_dyn_front_ || mb_dyn_rear_)) { - const auto F = inner_->tire_forces_body(); if (mb_dyn_front_ && mb_dae_front_) { mb::WheelLoad wl; - wl.force_world = 0.5 * (F[WHEEL_FL] + F[WHEEL_FR]); + wl.force_world = 0.5 * (contact_full_force_world_[WHEEL_FL] + + contact_full_force_world_[WHEEL_FR]); mb::step_hard_joint_dae(mb_state_front_, *mb_dae_front_, mb_topo_front_, axle_prescribed_motion(0, steer_rad), wl, dt); } if (mb_dyn_rear_ && mb_dae_rear_) { mb::WheelLoad wl; - wl.force_world = 0.5 * (F[WHEEL_RL] + F[WHEEL_RR]); + wl.force_world = 0.5 * (contact_full_force_world_[WHEEL_RL] + + contact_full_force_world_[WHEEL_RR]); mb::step_hard_joint_dae(mb_state_rear_, *mb_dae_rear_, mb_topo_rear_, axle_prescribed_motion(2, steer_rad), wl, dt); } } - for (int i = 0; i < NUM_WHEELS; ++i) road_dz_[i] = contacts[i].road_dz; + for (int i = 0; i < NUM_WHEELS; ++i) { + contact_valid_[i] = contacts[i].is_valid; + if (contact_valid_[i]) road_dz_[i] = contacts[i].road_dz; + } if (dt > 0.0) integrate_vertical(dt); write_pose_and_suspension(); } @@ -397,10 +449,13 @@ class FourteenDOFDynamics : public IVehicleDynamics { F_susp[WHEEL_RL] += Fr.first; F_susp[WHEEL_RR] += Fr.second; } double Fz_sum = 0.0, M_pitch_spring = 0.0, M_roll_spring = 0.0; + double M_pitch_contact = 0.0, M_roll_contact = 0.0; for (int i = 0; i < NUM_WHEELS; ++i) { Fz_sum += F_susp[i]; M_pitch_spring -= rx_[i] * F_susp[i]; M_roll_spring += ry_[i] * F_susp[i]; // includes the ARB roll contribution + M_roll_contact += contact_loads_[i].moment_delta_body.x(); + M_pitch_contact += contact_loads_[i].moment_delta_body.y(); } // Anti-dive / anti-squat reduces the longitudinal inertia moment fed @@ -419,9 +474,11 @@ class FourteenDOFDynamics : public IVehicleDynamics { d.dz_dot = Fz_sum / std::max(1.0, m_s); d.dphi = phi_dot; const double M_overturn = mx_[0] + mx_[1] + mx_[2] + mx_[3]; // camber contact migration - d.dphi_dot = (M_roll_spring + m_s * ay * h + M_overturn) / std::max(1e-3, Ixx); + d.dphi_dot = (M_roll_spring + m_s * ay * h + M_overturn + + M_roll_contact) / std::max(1e-3, Ixx); d.dth = th_dot; - d.dth_dot = (M_pitch_spring + M_inertia_pitch) / std::max(1e-3, Iyy); + d.dth_dot = (M_pitch_spring + M_inertia_pitch + + M_pitch_contact) / std::max(1e-3, Iyy); // Unsprung mass per corner: m_u · z̈_u = -F_susp(on sprung) - k_tire · z_u // = +F_susp_on_unsprung - k_tire · z_u @@ -430,7 +487,17 @@ class FourteenDOFDynamics : public IVehicleDynamics { const double m_u = std::max(1.0, vp_.unsprung_mass[i]); const double k_tire = std::max(1.0, ts_.for_wheel(i).tire_vertical_stiffness); d.dz_u[i] = zu_dot[i]; - d.dz_u_dot[i] = (-F_susp[i] - k_tire * (zu[i] - road_dz_[i])) / m_u; + // Invalid contact is airborne: the unsprung state remains dynamic + // under suspension force, but no hidden flat-road/tire spring is + // synthesized from road_dz. + const double F_road = contact_valid_[i] + ? k_tire * (zu[i] - road_dz_[i]) : 0.0; + // The non-flat vertical contact increment is an external force on + // the unsprung mass. Sprung heave receives it exactly once through + // the suspension reaction; applying it directly to dz_dot as well + // would double-count work and momentum. + d.dz_u_dot[i] = (-F_susp[i] - F_road + + contact_force_delta_world_[i].z()) / m_u; } return d; } @@ -536,6 +603,10 @@ class FourteenDOFDynamics : public IVehicleDynamics { std::array z_u_dot_ {{0.0, 0.0, 0.0, 0.0}}; std::array mx_ {{0.0, 0.0, 0.0, 0.0}}; // camber overturning from inner std::array road_dz_ {{0.0, 0.0, 0.0, 0.0}}; + std::array contact_valid_ {{true, true, true, true}}; + std::array contact_loads_ {}; + std::array contact_force_delta_world_ {}; + std::array contact_full_force_world_ {}; // Optional hardpoint kinematics (Ld4 Stage D). When attached, replaces // the lumped vp_.camber_per_roll · phi heuristic for the corresponding axle. diff --git a/core/src/linear_tire.cpp b/core/src/linear_tire.cpp index 75814a79..7108eeed 100644 --- a/core/src/linear_tire.cpp +++ b/core/src/linear_tire.cpp @@ -5,6 +5,8 @@ #include "vdsim/interfaces.hpp" +#include +#include #include namespace vdsim { @@ -25,6 +27,9 @@ class LinearTire final : public ITireModel { out.Fx = C_kappa_ * in.kappa * in.mu_long; out.Fy = -C_alpha_ * in.alpha * in.mu_lat; out.Mz = 0.0; + out.mu_peak = std::max(in.mu_long, in.mu_lat); + out.alpha_peak = std::numeric_limits::infinity(); + out.kappa_peak = std::numeric_limits::infinity(); return out; } diff --git a/core/src/magic_formula_tire.cpp b/core/src/magic_formula_tire.cpp index 44d97cff..b2335931 100644 --- a/core/src/magic_formula_tire.cpp +++ b/core/src/magic_formula_tire.cpp @@ -163,6 +163,9 @@ class MagicFormulaTire final : public ITireModel { out.Fx = Fx; out.Fy = Fy; out.Mz = Mz; + out.mu_peak = std::max(std::abs(mux), std::abs(muy)); + out.alpha_peak = mf_peak_slip(By, Cy, Ey); + out.kappa_peak = mf_peak_slip(Bx, Cx, Ex); return out; } diff --git a/core/src/pacejka_mf96.cpp b/core/src/pacejka_mf96.cpp index 9a05d798..496b1a18 100644 --- a/core/src/pacejka_mf96.cpp +++ b/core/src/pacejka_mf96.cpp @@ -93,6 +93,9 @@ class PacejkaMF96 final : public ITireModel { out.Fx = Fx; out.Fy = Fy + Fy_camber; out.Mz = -trail * Fy + Mz_camber; + out.mu_peak = std::max(tp_.D_long * mu_x, tp_.D_lat * mu_y); + out.alpha_peak = mf_peak_slip(tp_.B_lat, tp_.C_lat, tp_.E_lat); + out.kappa_peak = mf_peak_slip(tp_.B_long, tp_.C_long, tp_.E_long); return out; } }; diff --git a/core/src/params.cpp b/core/src/params.cpp index 9fcc1e54..005b6df6 100644 --- a/core/src/params.cpp +++ b/core/src/params.cpp @@ -196,6 +196,8 @@ VehicleParams VehicleParams::from_yaml(const std::string& path) { } pull(root, "lsd_preload", p.lsd_preload); pull(root, "lsd_ramp", p.lsd_ramp); + pull(root, "drive_split_front", p.drive_split_front); + pull(root, "plant_path", p.plant_path); pull(root, "max_motor_torque", p.max_motor_torque); pull(root, "final_drive_ratio", p.final_drive_ratio); pull(root, "engine_rotational_inertia", p.engine_rotational_inertia); @@ -265,6 +267,8 @@ void VehicleParams::to_yaml(const std::string& path) const { out << YAML::Key << "differential" << YAML::Value << diff_to_string(differential); out << YAML::Key << "lsd_preload" << YAML::Value << lsd_preload; out << YAML::Key << "lsd_ramp" << YAML::Value << lsd_ramp; + out << YAML::Key << "drive_split_front" << YAML::Value << drive_split_front; + out << YAML::Key << "plant_path" << YAML::Value << plant_path; out << YAML::Key << "max_motor_torque" << YAML::Value << max_motor_torque; out << YAML::Key << "final_drive_ratio" << YAML::Value << final_drive_ratio; out << YAML::Key << "engine_rotational_inertia" << YAML::Value << engine_rotational_inertia; diff --git a/core/src/seven_dof_dynamics.cpp b/core/src/seven_dof_dynamics.cpp index 5fd7d258..65980283 100644 --- a/core/src/seven_dof_dynamics.cpp +++ b/core/src/seven_dof_dynamics.cpp @@ -15,6 +15,7 @@ // - Static Fz_z balance only; no aerodynamic lift, no anti-dive geometry. #include "vdsim/coordinate.hpp" +#include "vdsim/contact_frame.hpp" #include "vdsim/default_subsystems.hpp" #include "vdsim/drivetrain_inertia.hpp" #include "vdsim/steering_kinematics.hpp" @@ -23,6 +24,8 @@ #include "vdsim/low_speed.hpp" #include "vdsim/subsystems.hpp" +#include "contact_frame_internal.hpp" + #include #include @@ -117,6 +120,9 @@ class SevenDOFDynamics final : public IVehicleDynamics { contact_dy_.fill(0.0); mx_w_.fill(0.0); transient_.fill(ITireModel::Transient{}); + contact_active_.fill(false); + contact_loads_.fill(detail::ContactLoad{}); + l4_contact_kinematics_ = detail::L4ContactKinematics{}; ci_.fill(ITireModel::ContactInput{}); drivetrain_ = make_default_drivetrain(vp_, vp_.drive_deadtime_s); brake_ = make_default_brake(vp_, vp_.brake_deadtime_s); @@ -189,6 +195,16 @@ class SevenDOFDynamics final : public IVehicleDynamics { int current_gear() const override { return drivetrain_->current_gear(); } bool set_shift_policy(ShiftPolicy fn) override { return drivetrain_->set_shift_policy(std::move(fn)); } + + /** Set one-step L4 spatial kinematics without extending the public plant interface. */ + void set_l4_contact_kinematics(const detail::L4ContactKinematics& context) noexcept { + l4_contact_kinematics_ = context; + } + + /** Return full contact loads and corrections for the internal L4 consumer. */ + const std::array& contact_loads() const noexcept { + return contact_loads_; + } bool set_brake_module(std::shared_ptr m) override { if (!m) return false; brake_ = std::move(m); @@ -285,35 +301,102 @@ class SevenDOFDynamics final : public IVehicleDynamics { const double R = vp_.wheel_radius_nominal; const double h_cg = vp_.cg_height; - // ---- Road slope from contact normals (flat -> no effect) ---- - // Average unit normal (world). Normal load scales by cos(slope)=n_z; the - // gravity component tangential to the road is felt as a body force. - Vec3 n_road = contacts[0].normal + contacts[1].normal - + contacts[2].normal + contacts[3].normal; + // ---- Exact road frame and gravity resolution (flat -> no effect) ---- + // Contacts carry world-ENU normals. Resolve one common road frame without + // a small-angle approximation: x_road is body-forward projected onto the + // road plane and y_road completes the right-handed frame. D1 already adds + // the non-flat normal-force increment to the planar EOM, so the gravity + // term below is applied as a residual after that increment is known. + Vec3 n_road = Vec3::Zero(); + for (const auto& contact : contacts) { + if (!contact.is_valid || !contact.normal.allFinite()) continue; + const double normal_norm = contact.normal.norm(); + if (normal_norm > 1e-12) n_road += contact.normal / normal_norm; + } const double nn = n_road.norm(); - if (nn > 1e-9) n_road /= nn; else n_road = Vec3::UnitZ(); - const double cos_slope = std::max(0.1, n_road.z()); - const double gdotn = -kGravity * n_road.z(); // (0,0,-g) . n - const double gtx = -gdotn * n_road.x(); // tangential gravity (world) - const double gty = -gdotn * n_road.y(); - const double gx_b = std::cos(yaw) * gtx + std::sin(yaw) * gty; // -> body - const double gy_b = -std::sin(yaw) * gtx + std::cos(yaw) * gty; + const bool has_road_normal = nn > 1e-9; + if (has_road_normal) n_road /= nn; + const double cos_slope = has_road_normal + ? std::max(0.0, n_road.z()) : 0.0; + const Vec3 forward_world(std::cos(yaw), std::sin(yaw), 0.0); + Vec3 x_road = forward_world; + Vec3 y_road(-std::sin(yaw), std::cos(yaw), 0.0); + if (has_road_normal) { + x_road -= x_road.dot(n_road) * n_road; + const double x_norm = x_road.norm(); + if (x_norm > 1e-12) { + x_road /= x_norm; + y_road = n_road.cross(x_road).normalized(); + } else { + // Degenerate: body-forward is (anti)parallel to the contact + // normal (near-vertical surface faced head-on). Do NOT fail + // open: leaving x_road unnormalized gives it length ~0, which + // silently zeroes gx/gy_gravity and drops gravity out of the + // planar EOM. Rebuild an explicit orthonormal road frame from + // body-lateral and surface the event (flag + one-shot warning). + Vec3 lateral(-std::sin(yaw), std::cos(yaw), 0.0); + lateral -= lateral.dot(n_road) * n_road; + if (lateral.norm() > 1e-12) { + y_road = lateral.normalized(); + } else { + Vec3 seed = n_road.cross(Vec3::UnitZ()); + if (seed.norm() <= 1e-12) seed = n_road.cross(Vec3::UnitY()); + y_road = seed.normalized(); + } + x_road = y_road.cross(n_road).normalized(); + road_frame_degenerate_ = true; + if (!road_frame_degenerate_logged_) { + road_frame_degenerate_logged_ = true; + spdlog::warn("[L2 7-DOF] degenerate road frame: heading is " + "parallel to the contact normal " + "n=({:.3f},{:.3f},{:.3f}); road frame rebuilt " + "from body-lateral (gravity kept in the EOM)", + n_road.x(), n_road.y(), n_road.z()); + } + } + } + const double gx_gravity = -kGravity * x_road.z(); + const double gy_gravity = -kGravity * y_road.z(); + const double nx_b = std::cos(yaw) * n_road.x() + std::sin(yaw) * n_road.y(); + const double ny_b = -std::sin(yaw) * n_road.x() + std::cos(yaw) * n_road.y(); // Specific force the chassis feels in the road-tangent plane = inertial // accel minus the tangential gravity the tires already react. On a banked // straight ay_kin->0 but ay_felt = -gy_b != 0, so the bank/slope produces // quasi-static load transfer (and the reported roll/pitch). Flat: ==0. - const double ax_felt = ax_prev_ - gx_b; - const double ay_felt = ay_prev_ - gy_b; + const double ax_felt = ax_prev_ - gx_gravity; + const double ay_felt = ay_prev_ - gy_gravity; ax_felt_ = ax_felt; ay_felt_ = ay_felt; // ---- Per-tire Fz with 1-step lag weight transfer + aero downforce ---- const double q_aero = 0.5 * kAirDensity * vp_.frontal_area * vx * std::abs(vx); const double Fz_aero_f_per = 0.5 * vp_.aero_lift_front * q_aero; const double Fz_aero_r_per = 0.5 * vp_.aero_lift_rear * q_aero; - const double Fz_static_f = m * kGravity * cos_slope * b / (2.0 * L) + Fz_aero_f_per; - const double Fz_static_r = m * kGravity * cos_slope * a / (2.0 * L) + Fz_aero_r_per; - const double g_perp = kGravity * cos_slope; // gravity normal to road + // Exact normal-force balance (no small-angle step). Dotting + // m*a = m*g_vec + N*n + F_tangential (F_tangential . n == 0) + // with n gives N/m = g*n_z + a.n. For a vehicle that stays on a + // locally planar surface v.n == 0, so a.n = -v.n_dot: the term exists + // only because the normal turns under the vehicle. With the normal + // carried around by the yaw rate (banked-track geometry) + // n_dot = r * (e_z x n), hence + // a.n = r * (v_x*n_y(body) - v_y*n_x(body)), + // whose leading part is the textbook centripetal contribution + // (v_x*r)*n_y. Force-path load, never an equivalent lateral-accel + // offset. NOTE a tangential force must not enter here: on a fixed + // inclined plane N = m*g*cos(theta) whatever a_x is (braking/traction + // accelerates the CG inside the road plane, so its a.n is zero) - see + // GradeAndBankCombinedNormalLoadIsBrakingIndependent. + // Limitation (P3-5): the max(0,.) clamp is a C1 cliff. At bank b with + // the heading along the bank axis the bracket vanishes at + // v_x*r = -g*cos(b)/sin(b) = -g/tan(b) (-21.0 m/s^2 at b = 25 deg), + // where all four Fz reach 0 together and the load path stops being + // differentiable. Valid range: g*n_z + a.n > 0. + const double g_perp = has_road_normal + ? std::max(0.0, kGravity * cos_slope + + centripetal_accel_substep_ * ny_b + - vy_r_substep_ * nx_b) : 0.0; + const double Fz_static_f = m * g_perp * b / (2.0 * L) + Fz_aero_f_per; + const double Fz_static_r = m * g_perp * a / (2.0 * L) + Fz_aero_r_per; // Longitudinal transfer + pitch, with CG-migration (jacking): a pitched // body shifts the CG by h_cg*sin(theta), adding a gravity pitch moment -> @@ -365,7 +448,20 @@ class SevenDOFDynamics final : public IVehicleDynamics { if (!contacts[i].is_valid) Fz[i] = 0.0; // L3 may supply a dynamic (ride/road-coupled) tire load for grip in place // of this quasi-static Fz. One-shot: consumed here, re-set each L3 step. + // KNOWN DEFECT (P1-2), left in place deliberately: only RK stage k1 sees + // the injected load, k2..k4 fall back to the quasi-static Fz above, and + // tire_Fz_ therefore reports the k4 (quasi-static) value. Making it + // step-scoped is a one-line change but moves the frozen D0 flat-road + // baseline (measured: L3 state 5.01e-2 / force 2.08e2 N, L4 state + // 6.67e-2 / force 1.82e2 N against tests/fixtures/d0_flat_contact_*), + // i.e. it is a behavior change on flat road and needs the D0 fixtures + // re-frozen as an explicit decision, not a silent one. if (use_ext_fz_) { Fz = ext_fz_; use_ext_fz_ = false; } + // `is_valid=false` is non-contact, including on the L3/L4 external-Fz + // path. Re-mask after the override so no normal load, rolling + // resistance, tire wrench, or transient update leaks through. + for (int i = 0; i < NUM_WHEELS; ++i) + if (!contacts[i].is_valid) Fz[i] = 0.0; const DriverCmd driver_cmd{ steer_cmd, @@ -410,6 +506,7 @@ class SevenDOFDynamics final : public IVehicleDynamics { // ---- Per-tire velocity, slip, force ---- std::array F_body; + std::array F_applied; std::array tire_F_disp; // reported force (lateral faded by lambda) std::array tire_F_wheel_disp; // same, wheel frame (pre steer rotation) std::array kappa, alpha; @@ -443,8 +540,28 @@ class SevenDOFDynamics final : public IVehicleDynamics { std::array fx_kin {{0.0, 0.0, 0.0, 0.0}}; for (int i = 0; i < NUM_WHEELS; ++i) { - if (!contacts[i].is_valid) { + const double di = d_wheel[i]; + const double cd_i = std::cos(di), sd_i = std::sin(di); + // Contact normals are injected in world ENU. Resolve them into + // the planar body frame, then construct one shared exact contact + // basis for both L3 and L4 (which delegate through this path). + const Vec3& nw = contacts[i].normal; + const bool canonical_flat = nw.x() == 0.0 && nw.y() == 0.0 + && nw.z() > 0.0; + Vec3 normal_body; + if (l4_contact_kinematics_.enabled && !canonical_flat) { + normal_body = l4_contact_kinematics_.body_to_world.conjugate() * nw; + } else { + normal_body = Vec3( + std::cos(yaw) * nw.x() + std::sin(yaw) * nw.y(), + -std::sin(yaw) * nw.x() + std::cos(yaw) * nw.y(), + nw.z()); + } + const ContactFrame contact_frame = make_contact_frame( + normal_body, Vec3(cd_i, sd_i, 0.0)); + if (!contacts[i].is_valid || !contact_frame.valid) { F_body[i] = Vec3::Zero(); + F_applied[i] = Vec3::Zero(); tire_F_disp[i] = Vec3::Zero(); tire_F_wheel_disp[i] = Vec3::Zero(); kappa[i] = 0.0; @@ -458,18 +575,29 @@ class SevenDOFDynamics final : public IVehicleDynamics { wheel_alpha_peak_[i] = 0.0; wheel_kappa_peak_[i] = 0.0; ci_[i] = ITireModel::ContactInput{}; // neutral: no bristle/relax evolution + transient_[i] = ITireModel::Transient{}; + contact_active_[i] = false; + contact_loads_[i] = detail::ContactLoad{}; continue; } const double v_x_body = vx - r * r_y[i]; const double v_y_body = vy + r * r_x[i]; - - const double di = d_wheel[i]; - // A wheel is "steered" if its angle is non-trivial (front or any - // wheel with non-zero toe input). We just always do the rotation - // — it's a cheap cos/sin per wheel. - const double cd_i = std::cos(di), sd_i = std::sin(di); - const double v_x_wheel = v_x_body * cd_i + v_y_body * sd_i; - const double v_y_wheel = -v_x_body * sd_i + v_y_body * cd_i; + Vec3 wheel_velocity_body(v_x_body, v_y_body, 0.0); + if (l4_contact_kinematics_.enabled && !canonical_flat) { + const Vec3 lever(r_x[i], r_y[i], -h_cg); + const Vec3 rigid_velocity = s.velocity + + l4_contact_kinematics_.angular_velocity_body.cross(lever); + Vec3 wheel_velocity_world = + l4_contact_kinematics_.body_to_world * rigid_velocity; + wheel_velocity_world.z() = + l4_contact_kinematics_.wheel_vertical_velocity_world[i]; + wheel_velocity_body = + l4_contact_kinematics_.body_to_world.conjugate() + * wheel_velocity_world; + } + const Vec3 contact_velocity = contact_frame.resolve_velocity( + wheel_velocity_body); + contact_active_[i] = true; const double mu_long_i = contacts[i].mu_long; const double mu_lat_i = contacts[i].mu_lat; @@ -483,7 +611,9 @@ class SevenDOFDynamics final : public IVehicleDynamics { // advance_bristle() / per-substep advance_relaxation() see this stage's // kinematics (mirrors the old *_last_ scratch). ITireModel::ContactInput ci; - ci.Fz = Fz[i]; ci.Vx = v_x_wheel; ci.Vy = v_y_wheel; + ci.Fz = Fz[i]; + ci.Vx = contact_velocity.x(); + ci.Vy = contact_velocity.y(); ci.omega = s.wheel_spin[i]; ci.gamma = camber_ext_[i]; ci.mu_long = mu_long_i; ci.mu_lat = mu_lat_i; ci.R0 = R; ci_[i] = ci; @@ -517,7 +647,7 @@ class SevenDOFDynamics final : public IVehicleDynamics { } else { const double hold_gate = plant ? 0.0 : (1.0 - lambda_i) * std::clamp(cmd.brake - cmd.throttle, 0.0, 1.0); - double Fx_hold = -kStickC * v_x_body * hold_gate; + double Fx_hold = -kStickC * contact_velocity.x() * hold_gate; if (std::abs(Fx_hold) > muFz) Fx_hold = std::copysign(muFz, Fx_hold); Fx_w = w.Fx + Fx_hold; Fy_w = lambda_i * w.Fy; @@ -533,16 +663,67 @@ class SevenDOFDynamics final : public IVehicleDynamics { Fy_w *= c; } if (lugre_i) fx_kin[i] = Fx_w; - const double Fx_b = Fx_w * cd_i - Fy_w * sd_i; - const double Fy_b = Fx_w * sd_i + Fy_w * cd_i; - F_body[i] = Vec3(Fx_b, Fy_b, 0.0); + F_body[i] = contact_frame.tangential_force(Fx_w, Fy_w); + const Vec3 full_contact_force = + contact_frame.contact_force(Fx_w, Fy_w, Fz[i]); + auto force_to_world = [&](const Vec3& force_body) { + if (l4_contact_kinematics_.enabled && !canonical_flat) { + return l4_contact_kinematics_.body_to_world * force_body; + } + return Vec3( + std::cos(yaw) * force_body.x() - std::sin(yaw) * force_body.y(), + std::sin(yaw) * force_body.x() + std::cos(yaw) * force_body.y(), + force_body.z()); + }; + auto force_from_world = [&](const Vec3& force_world) { + if (l4_contact_kinematics_.enabled && !canonical_flat) { + return l4_contact_kinematics_.body_to_world.conjugate() + * force_world; + } + return Vec3( + std::cos(yaw) * force_world.x() + std::sin(yaw) * force_world.y(), + -std::sin(yaw) * force_world.x() + std::cos(yaw) * force_world.y(), + force_world.z()); + }; + const Vec3 full_force_world = force_to_world(full_contact_force); + Vec3 applied_force_world; + if (canonical_flat) { + // Preserve the legacy arithmetic exactly on flat road: the + // suspension already carries Fz*world-z and the tire path only + // applies its tangential body force. + F_applied[i] = F_body[i]; + applied_force_world = force_to_world(F_applied[i]); + } else { + // road_dz and the L4 vertical spring are defined on world z. + // Subtract that legacy load on the same axis, then resolve the + // remaining physical force back to body coordinates for planar + // body equations. Subtracting Fz*body-z is incorrect at roll/pitch. + applied_force_world = full_force_world - Fz[i] * Vec3::UnitZ(); + F_applied[i] = force_from_world(applied_force_world); + } + contact_loads_[i].full_force_body = full_contact_force; + contact_loads_[i].applied_force_body = F_applied[i]; + contact_loads_[i].full_force_world = full_force_world; + contact_loads_[i].applied_force_world = applied_force_world; + if (canonical_flat) { + contact_loads_[i].moment_delta_body = Vec3::Zero(); + } else { + const double vertical_delta = F_applied[i].z(); + // The existing m*a*h terms already carry the horizontal-force + // lever arm, and suspension carries r_xy cross Fz*e_z. Only + // the non-flat vertical correction remains for roll/pitch. + contact_loads_[i].moment_delta_body = Vec3( + r_y[i] * vertical_delta, + -r_x[i] * vertical_delta, + 0.0); + } const double Fdm = std::hypot(Fxd, Fyd); if (!ts_.for_wheel(i).model_provides_combined_slip() && Fdm > muFz && Fdm > 1e-9) { const double c = muFz / Fdm; Fxd *= c; Fyd *= c; } - tire_F_disp[i] = Vec3(Fxd * cd_i - Fyd * sd_i, Fxd * sd_i + Fyd * cd_i, 0.0); + tire_F_disp[i] = contact_frame.tangential_force(Fxd, Fyd); tire_F_wheel_disp[i] = Vec3(Fxd, Fyd, 0.0); kappa[i] = k_slip; alpha[i] = a_slip; @@ -565,9 +746,9 @@ class SevenDOFDynamics final : public IVehicleDynamics { // ---- Body equations ---- double Fx_total = 0.0, Fy_total = 0.0, Mz_total = 0.0; for (int i = 0; i < NUM_WHEELS; ++i) { - Fx_total += F_body[i].x(); - Fy_total += F_body[i].y(); - Mz_total += r_x[i] * F_body[i].y() - r_y[i] * F_body[i].x(); + Fx_total += F_applied[i].x(); + Fy_total += F_applied[i].y(); + Mz_total += r_x[i] * F_applied[i].y() - r_y[i] * F_applied[i].x(); } // Per-tire Mz_wheel adds directly to body z (parallel axes, camber=0). for (int i = 0; i < NUM_WHEELS; ++i) Mz_total += mz_wheel[i]; @@ -580,8 +761,27 @@ class SevenDOFDynamics final : public IVehicleDynamics { F_rr *= std::tanh(vx / 0.5); Fx_total -= F_aero; Fx_total -= F_rr; - Fx_total += m * gx_b; // gravity tangential to the road (0 on flat) - Fy_total += m * gy_b; + // D1's applied force contains Fz*n in the planar components because the + // legacy suspension path subtracts only Fz*world-Z. Add exactly the + // residual required to obtain the road-frame gravity components. This + // avoids double-counting the normal contribution and is identically zero + // on canonical flat road. + // The leak is per wheel, sum_i Fz[i]*n_planar(i); an average normal + // reproduces it only when all four normals are equal. Heterogeneous + // normals (one wheel on a banked patch) otherwise leave a spurious + // in-plane force sum_i Fz[i]*n(i) - (sum_i Fz[i])*n_avg (1.1 kN, + // 0.76 m/s^2 for 30 deg under one wheel). F_applied - F_body is + // exactly Fz[i]*(n_body(i) - R^T e_z), i.e. the leaked term in the very + // frame D1 added it in (full quaternion on L4, yaw-only otherwise), so + // this cancels identically - and is exactly zero on canonical flat road. + double normal_leak_x = 0.0; + double normal_leak_y = 0.0; + for (int i = 0; i < NUM_WHEELS; ++i) { + normal_leak_x += F_applied[i].x() - F_body[i].x(); + normal_leak_y += F_applied[i].y() - F_body[i].y(); + } + Fx_total += m * gx_gravity - normal_leak_x; + Fy_total += m * gy_gravity - normal_leak_y; Deriv d_out; d_out.dx_world = vx * std::cos(yaw) - vy * std::sin(yaw); @@ -680,7 +880,7 @@ class SevenDOFDynamics final : public IVehicleDynamics { // ---- Diagnostics ---- tire_F_ = tire_F_disp; tire_F_wheel_ = tire_F_wheel_disp; - tire_Fz_ = Fz; + tire_Fz_ = Fz; // scalar normal-load magnitude along each contact z_c slip_ratio_ = kappa; slip_angle_ = alpha; @@ -714,6 +914,7 @@ class SevenDOFDynamics final : public IVehicleDynamics { // kinematics of the stage just evaluated (stored in ci_). void advance_bristle_(double dt) { for (int i = 0; i < NUM_WHEELS; ++i) { + if (!contact_active_[i]) continue; if (!ts_.for_wheel(i).lugre.enabled) continue; transient_[i] = wheel_tire_model(tire_, i) .advance_bristle(ci_[i], transient_[i], dt); @@ -721,13 +922,27 @@ class SevenDOFDynamics final : public IVehicleDynamics { } // Carcass/belt + relaxation-length lag — advanced once per substep at the full dt, void advance_relaxation_(double dt) { - for (int i = 0; i < NUM_WHEELS; ++i) - transient_[i] = wheel_tire_model(tire_, i) - .advance_relaxation(ci_[i], transient_[i], dt); + for (int i = 0; i < NUM_WHEELS; ++i) { + if (contact_active_[i]) { + transient_[i] = wheel_tire_model(tire_, i) + .advance_relaxation(ci_[i], transient_[i], dt); + } + } } void substep(const ContactArray& contacts, double h) { const State s0 = state_; + // Keep the normal-load forcing fixed across the four RK stages. Stage- + // local yaw-rate drift otherwise breaks the exact +/-bank mirror even as + // h->0. It is refreshed at every physical substep, not held for a full + // caller step when substepping is active. + // Known limitation (P3-7): freezing this forcing over the four stages + // leaves the load channel first-order accurate in h (its state- + // dependent part is held at the stage-0 value) even though the state + // integrator is RK4. Not restructured here; substep size bounds it. + centripetal_accel_substep_ = s0.velocity.x() + * s0.angular_velocity.z(); + vy_r_substep_ = s0.velocity.y() * s0.angular_velocity.z(); const bool lugre_on = [&]() { for (int wi = 0; wi < NUM_WHEELS; ++wi) if (ts_.for_wheel(wi).lugre.enabled) return true; @@ -790,6 +1005,12 @@ class SevenDOFDynamics final : public IVehicleDynamics { double ay_prev_ {0.0}; double ax_felt_ {0.0}; // specific force (accel - tangential gravity) for transfer/attitude double ay_felt_ {0.0}; + double centripetal_accel_substep_ {0.0}; // v_x*r fixed across one RK substep [m/s^2] + double vy_r_substep_ {0.0}; // v_y*r fixed across one RK substep [m/s^2] + // Set when body-forward was (anti)parallel to the contact normal and the + // road frame had to be rebuilt (see derivatives()); warned once. + bool road_frame_degenerate_ {false}; + bool road_frame_degenerate_logged_ {false}; double roll_qs_ {0.0}; // quasi-static roll/pitch incl. CG-migration (set in step) double pitch_qs_ {0.0}; double mz_front_sum_ {0.0}; @@ -810,6 +1031,9 @@ class SevenDOFDynamics final : public IVehicleDynamics { // advance_bristle()/advance_relaxation(). ci_ holds the contact kinematics the tire // was last evaluated at (per stage), feeding those advance calls. std::array transient_ {}; + std::array contact_active_ {{false, false, false, false}}; + detail::L4ContactKinematics l4_contact_kinematics_ {}; + std::array contact_loads_ {}; mutable std::array ci_ {}; mutable std::array Re_w_ {{0.31, 0.31, 0.31, 0.31}}; // effective rolling radius per wheel mutable std::array contact_dy_ {{0.0, 0.0, 0.0, 0.0}}; // camber lateral contact offset @@ -833,4 +1057,20 @@ std::unique_ptr create_seven_dof(std::unique_ptr t return std::make_unique(std::move(tire)); } +bool detail::set_l4_contact_kinematics( + IVehicleDynamics& dynamics, + const detail::L4ContactKinematics& context) noexcept { + auto* seven = dynamic_cast(&dynamics); + if (seven == nullptr) return false; + seven->set_l4_contact_kinematics(context); + return true; +} + +std::array detail::contact_loads( + const IVehicleDynamics& dynamics) noexcept { + const auto* seven = dynamic_cast(&dynamics); + if (seven == nullptr) return {}; + return seven->contact_loads(); +} + } // namespace vdsim diff --git a/core/src/tire_model.cpp b/core/src/tire_model.cpp index b60bc1fb..aa192c35 100644 --- a/core/src/tire_model.cpp +++ b/core/src/tire_model.cpp @@ -51,12 +51,19 @@ ITireModel::Wrench ITireModel::evaluate(const ContactInput& ci, const Transient& w.Fx = l.Fx; w.Fy = l.Fy; w.Mz = l.Mz; + const auto mfp = compute(in); + w.mu_peak = mfp.mu_peak; + w.alpha_peak = mfp.alpha_peak; + w.kappa_peak = mfp.kappa_peak; } else { const auto out = compute(in); w.Fx = out.Fx; w.Fy = out.Fy; w.Mz = out.Mz; w.Mx += out.Mx; // carcass Mx from the force law (0 for the simple laws), plus camber Mx + w.mu_peak = out.mu_peak; + w.alpha_peak = out.alpha_peak; + w.kappa_peak = out.kappa_peak; } return w; } diff --git a/cosim/CMakeLists.txt b/cosim/CMakeLists.txt index d44df4b4..95e6ac23 100644 --- a/cosim/CMakeLists.txt +++ b/cosim/CMakeLists.txt @@ -23,7 +23,7 @@ if(Python3_FOUND) set_tests_properties(cosim_multi_vehicle PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} TIMEOUT 30 - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") add_test(NAME l3_scenario_susp_paths COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_l3_scenario_paths.py) @@ -34,95 +34,176 @@ if(Python3_FOUND) ${CMAKE_SOURCE_DIR}/tests/scripts/test_runconfig_roundtrip.py) set_tests_properties(runconfig_roundtrip PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") add_test(NAME catalog_resolver COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_catalog_resolver.py) set_tests_properties(catalog_resolver PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME scene_materialize COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_scene_materialize.py) set_tests_properties(scene_materialize PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME catalog_api COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_catalog_api.py) set_tests_properties(catalog_api PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") add_test(NAME import_part_pack COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_import_part_pack.py) set_tests_properties(import_part_pack PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME level_names COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_level_names.py) set_tests_properties(level_names PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME module_workshop COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_module_workshop.py) set_tests_properties(module_workshop PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME assembly_api COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_assembly_api.py) set_tests_properties(assembly_api PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME part_store COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_part_store.py) set_tests_properties(part_store PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME blueprint_roundtrip COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_blueprint_roundtrip.py) set_tests_properties(blueprint_roundtrip PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME multi_vehicle_compare COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_compare.py) set_tests_properties(multi_vehicle_compare PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME experiment_api COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_experiment_api.py) set_tests_properties(experiment_api PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME cmdl1_binding COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_cmdl1_binding.py) set_tests_properties(cmdl1_binding PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME vla_plant COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/tests/scripts/test_vla_plant.py) set_tests_properties(vla_plant PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") + # GUI v3 is a deferred (node-stack) component. Its tests are excluded from + # the canonical `validation` preset as a component, not one target at a + # time, and are listed by name on the `excluded:` lines of + # docs/VALIDATION.md (07_p0_demo.md 6.4(2)). + if(VDSIM_BUILD_GUI_V3_TESTS) + add_test(NAME gui_v3_api_smoke + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_gui_v3_api_smoke.py) + set_tests_properties(gui_v3_api_smoke PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") + add_test(NAME gui_v3_e2e + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_gui_v3_e2e.py) + set_tests_properties(gui_v3_e2e PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 180 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python:${CMAKE_SOURCE_DIR}/cosim:${CMAKE_SOURCE_DIR}/gui") + endif() add_test(NAME vla_plant_demo COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/examples/vla_plant_demo.py) set_tests_properties(vla_plant_demo PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") add_test(NAME control_ladder COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/examples/control_ladder_demo.py) set_tests_properties(control_ladder PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - ENVIRONMENT "PYTHONPATH=${CMAKE_SOURCE_DIR}/build/python:${CMAKE_SOURCE_DIR}/python") + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") + add_test(NAME trace_roundtrip + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_trace_roundtrip.py) + set_tests_properties(trace_roundtrip PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + # The trace tests deliberately do NOT put build/python on the path: reading + # and rendering a trace must work where the compiled core cannot be built. + add_test(NAME trace_render + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_trace_render.py) + set_tests_properties(trace_render PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 180 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + add_test(NAME trace_render_multi + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_trace_multi_render.py) + set_tests_properties(trace_render_multi PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 300 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + add_test(NAME render_presets + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_render_presets.py) + set_tests_properties(render_presets PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 300 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + add_test(NAME trace_horizons + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_trace_horizons.py) + set_tests_properties(trace_horizons PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 300 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + # AC M1 is a standalone Python importer: synthetic data only, no core/KN5. + add_test(NAME ac_fast_lane_importer + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_ac_fast_lane_importer.py) + set_tests_properties(ac_fast_lane_importer PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 120 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/python") + # Text TX templates end-to-end: json + nmea_gga must reach a UDP socket. + add_test(NAME comms_tx_templates + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_comms_tx_templates.py) + set_tests_properties(comms_tx_templates PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 60 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") + # Registered only when its driver script is present in the tree: the CarMaker + # ROS interface compatibility check ships with the optional cmrosif bridge. + if(EXISTS ${CMAKE_SOURCE_DIR}/tests/scripts/test_cmrosif_compat.py) + add_test(NAME cmrosif_compat + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/scripts/test_cmrosif_compat.py) + set_tests_properties(cmrosif_compat PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_SOURCE_DIR}/cosim") + endif() endif() diff --git a/cosim/cmrosif_compat.py b/cosim/cmrosif_compat.py new file mode 100644 index 00000000..70fc9b7b --- /dev/null +++ b/cosim/cmrosif_compat.py @@ -0,0 +1,163 @@ +"""CarMaker cmrosif message ↔ VDS1 UDP conversions (no ROS dependency). + +Topic names match IPG CMRosIF defaults in CMNode_ROS1.cpp so existing +controllers (e.g. carmaker_vds_client/cm_ablation_controller.py) can subscribe +to /carmaker/dynamic_info and publish /carmaker/control_signal unchanged. +""" +from __future__ import annotations + +import math +from typing import Any, Mapping, MutableMapping + +DEFAULT_TOPICS = { + "control_signal": "/carmaker/control_signal", + "dynamic_info": "/carmaker/dynamic_info", + "uaq_out": "/carmaker/uaq_out", + "gnss": "/carmaker/gnss", + "cmremote": "/carmaker/cmremote", +} + +DEFAULT_VEHICLE = { + "steer_ratio": 14.46, + "wheelbase_m": 3.0, + "max_decel_mps2": 10.0, + "max_steer_wheel_rad": 480.0 * math.pi / 180.0, +} + + +def _clamp(v: float, lo: float, hi: float) -> float: + return max(lo, min(hi, v)) + + +def control_signal_to_vds1( + steerangle: float, + brake: float, + gas: float, + gear: int = 1, + *, + steer_ratio: float = DEFAULT_VEHICLE["steer_ratio"], + max_steer_wheel_rad: float = DEFAULT_VEHICLE["max_steer_wheel_rad"], +) -> dict[str, float | int]: + """carmaker_msgs/Control_Signal → VDS1 CMD fields.""" + sa = steerangle + if not math.isfinite(sa): + sa = 0.0 + sa = _clamp(sa, -max_steer_wheel_rad, max_steer_wheel_rad) + g = _clamp(float(gas) if math.isfinite(gas) else 0.0, 0.0, 1.0) + b = _clamp(float(brake) if math.isfinite(brake) else 0.0, 0.0, 1.0) + return { + "steer": sa / float(steer_ratio), + "throttle": g, + "brake": b, + "gear": int(gear), + } + + +def vds1_state_to_dynamic_info( + st: Mapping[str, Any], + *, + steer_ratio: float = DEFAULT_VEHICLE["steer_ratio"], + cycleno: int = 0, + synthdelay: float = 0.0, +) -> dict[str, float | int]: + """VDS1 decoded STATE → carmaker_msgs/DynamicsInfo field dict.""" + ws = list(st.get("wheel_spin") or [0.0, 0.0, 0.0, 0.0]) + while len(ws) < 4: + ws.append(0.0) + steer_wheel = float(st.get("steer_applied", 0.0)) * float(steer_ratio) + out: dict[str, float | int] = { + "cycleno": int(cycleno), + "synthdelay": float(synthdelay), + "latitude": 0.0, + "longitude": 0.0, + "altitude": float(st.get("z", 0.0)), + "Car_Roll": float(st.get("roll", 0.0)), + "Car_Pitch": float(st.get("pitch", 0.0)), + "Car_Yaw": float(st.get("yaw", 0.0)), + "Car_vx": float(st.get("vx", 0.0)), + "Car_vy": float(st.get("vy", 0.0)), + "Car_vz": float(st.get("vz", 0.0)), + "Car_RollVel": float(st.get("roll_rate", 0.0)), + "Car_PitchVel": float(st.get("pitch_rate", 0.0)), + "Car_YawVel": float(st.get("yaw_rate", 0.0)), + "Car_ax": float(st.get("ax", 0.0)), + "Car_ay": float(st.get("ay", 0.0)), + "Car_az": 0.0, + "Car_RollAcc": 0.0, + "Car_PitchAcc": 0.0, + "Car_YawAcc": 0.0, + "Steer_WhlAng": steer_wheel, + "VC_Gas": float(st.get("throttle_applied", 0.0)), + "VC_Brake": float(st.get("brake_applied", 0.0)), + "VC_SelectorCtrl": 1.0, + "Vhcl_FL_rotv": float(ws[0]), + "Vhcl_FR_rotv": float(ws[1]), + "Vhcl_RL_rotv": float(ws[2]), + "Vhcl_RR_rotv": float(ws[3]), + "Vhcl_FL_rz": 0.0, + "Vhcl_FR_rz": 0.0, + "Vhcl_RL_rz": 0.0, + "Vhcl_RR_rz": 0.0, + "PowerTrain_MotorIF_0_rotv": 0.0, + "PowerTrain_MotorIF_0_Trq": 0.0, + "PowerTrain_MotorIF_0_PwrElec": 0.0, + "PowerTrain_MotorIF_1_rotv": 0.0, + "PowerTrain_MotorIF_1_Trq": 0.0, + "PowerTrain_MotorIF_1_PwrElec": 0.0, + } + _fill_imu_fields(out, st) + return out + + +def vds1_state_to_uaq_out( + st: Mapping[str, Any], + *, + steer_ratio: float = DEFAULT_VEHICLE["steer_ratio"], + cycleno: int = 0, + synthdelay: float = 0.0, +) -> dict[str, float | int]: + base = vds1_state_to_dynamic_info( + st, steer_ratio=steer_ratio, cycleno=cycleno, synthdelay=synthdelay) + return {k: v for k, v in base.items() + if not k.startswith(("latitude", "longitude", "altitude", "PowerTrain"))} + + +def _fill_imu_fields(out: MutableMapping[str, float], st: Mapping[str, Any]) -> None: + vx, vy, vz = float(st.get("vx", 0.0)), float(st.get("vy", 0.0)), float(st.get("vz", 0.0)) + wx = float(st.get("roll_rate", 0.0)) + wy = float(st.get("pitch_rate", 0.0)) + wz = float(st.get("yaw_rate", 0.0)) + ax, ay = float(st.get("ax", 0.0)), float(st.get("ay", 0.0)) + for prefix in ("Sensor_Inertial_0", "Sensor_Inertial_1"): + out[f"{prefix}_Vel_B_x"] = vx + out[f"{prefix}_Vel_B_y"] = vy + out[f"{prefix}_Vel_B_z"] = vz + out[f"{prefix}_Omega_B_x"] = wx + out[f"{prefix}_Omega_B_y"] = wy + out[f"{prefix}_Omega_B_z"] = wz + out[f"{prefix}_Acc_B_x"] = ax + out[f"{prefix}_Acc_B_y"] = ay + out[f"{prefix}_Acc_B_z"] = 0.0 + out[f"{prefix}_Aplha_B_x"] = 0.0 + out[f"{prefix}_Aplha_B_y"] = 0.0 + out[f"{prefix}_Aplha_B_z"] = 0.0 + + +def fill_ros_msg(msg: Any, fields: Mapping[str, float | int]) -> None: + for key, val in fields.items(): + if hasattr(msg, key): + setattr(msg, key, val) + + +def parse_cmremote(req_type: str, req_msg: str) -> str | None: + """Map cmrosutils/CMRemote request → plant action ('start'|'stop'|'pause').""" + blob = f"{req_type or ''} {req_msg or ''}".strip().lower() + if not blob: + return None + if any(k in blob for k in ("simstart", "start", "play", "run")): + return "start" + if any(k in blob for k in ("simstop", "stop", "halt", "end")): + return "stop" + if "pause" in blob: + return "pause" + return None diff --git a/cosim/comms_templates.hpp b/cosim/comms_templates.hpp new file mode 100644 index 00000000..2fff94fc --- /dev/null +++ b/cosim/comms_templates.hpp @@ -0,0 +1,572 @@ +// Text TX templates for the realtime comms router: JSON state and NMEA 0183 GGA. +// +// The canonical binary VDS1 framing lives in cosim_protocol.hpp; this header +// holds the human-readable templates a scene may select per channel with +// `template: json` / `template: nmea_gga` (see configs/comms/*.yaml). +// Header-only so the encoders are unit-testable without linking the server. +#pragma once + +#include "cosim_protocol.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace vdsim::cosim { + +// TX scratch buffer size used by the realtime server: must hold the binary VDS1 +// state frame and the longest text template (JSON state ~340 B; GGA ~80 B). +constexpr size_t kTxBufBytes = 512; +static_assert(kTxBufBytes >= static_cast(kStateBytes), + "TX buffer must also fit a binary VDS1 state frame"); + +// Geodetic datum a comms channel's local ENU metres are referenced to. +// Scene YAML: `origin: {lat: 37.5, lon: 127.0, alt: 30.0}`. Default (0,0,0) +// keeps every existing scene working (Null Island) without a schema change. +struct GeodeticOrigin { + double lat_deg {0.0}; + double lon_deg {0.0}; + double alt_m {0.0}; +}; + +// Result of enu_to_geodetic(): WGS84-referenced latitude/longitude + altitude. +// +// `ok` is the only field a caller may read unconditionally. When it is true, +// lat_deg is in [-90, 90] and lon_deg in (-180, 180]; when it is false the +// three angles/altitude are NaN, because the ECEF->geodetic inverse is not +// single-valued at that point (see enu_to_geodetic()). The defaults hold that +// invariant too: a default-constructed Geodetic is ok = false with NaN angles, +// so a caller that skips the projection entirely — or forgets to test ok — +// cannot accidentally publish a zeroed "fix at Null Island". +struct Geodetic { + double lat_deg {std::numeric_limits::quiet_NaN()}; + double lon_deg {std::numeric_limits::quiet_NaN()}; + double alt_m {std::numeric_limits::quiet_NaN()}; + bool ok {false}; +}; + +// Synthetic GGA quality fields. VDSim models GNSS as an additive-noise position +// sensor (core/src/sensor_model.cpp) — there is no constellation, DOP or +// differential model — so these are fixed, clearly-labelled placeholders that +// keep the sentence parseable by real receivers/consumers. +constexpr int kGgaFixQuality = 1; // 1 = autonomous GPS fix +constexpr int kGgaSatellites = 12; // placeholder: no constellation model +constexpr double kGgaHdop = 0.9; // placeholder: no DOP model +constexpr double kGgaGeoidSep = 0.0; // placeholder: no geoid undulation model + +// Largest ENU offset a GGA position is reported for. Two-and-a-half Earth +// circumferences from the datum is not a vehicle, it is a diverged solver (or +// an uninitialised state), so the sentence reports "no fix" (quality 0, empty +// position fields) instead of a plausible-looking coordinate. +constexpr double kMaxEnuOffsetM = 1.0e8; + +// Largest |altitude| printed in the GGA altitude field. Outside this the field +// is emitted empty ("not available"): "%.1f" of, say, 1e300 would need 300+ +// characters and could not be formatted into a fixed-size field buffer. +constexpr double kMaxGgaAltitudeM = 1.0e7; + +/// @brief Canonicalise a scene TX template name to the encoder it selects. +/// @param templ Raw `template:` string from the scene comms spec. +/// @return "vds1", "json" or "nmea_gga"; empty string if the name is unknown. +/// @note `vds1_state` is the long-form alias of `vds1`; both map to the binary +/// VDS1 STATE frame. An empty input keeps the historical `vds1` default. +inline std::string canonical_tx_template(const std::string& templ) { + if (templ.empty() || templ == "vds1" || templ == "vds1_state") return "vds1"; + if (templ == "json") return "json"; + if (templ == "nmea_gga") return "nmea_gga"; + return std::string(); +} + +/// @brief NMEA 0183 checksum: XOR of every char strictly between '$' and '*'. +/// @param body Sentence body (talker+type+fields), without '$' or '*'. +/// @return 8-bit checksum, printed by nmea_sentence() as two uppercase hex digits. +inline uint8_t nmea_checksum(const std::string& body) { + uint8_t sum = 0; + for (const char c : body) sum ^= static_cast(c); + return sum; +} + +/// @brief Frame comma-separated NMEA fields into a complete sentence. +/// @param fields fields[0] is the sentence id ("GPGGA"); the rest are data +/// fields in spec order. Empty strings emit empty (null) fields, which +/// is how NMEA encodes "not available". +/// @return "$,,...*\r\n" with an uppercase two-digit checksum. +inline std::string nmea_sentence(const std::vector& fields) { + std::string body; + for (size_t i = 0; i < fields.size(); ++i) { + if (i) body += ','; + body += fields[i]; + } + char tail[8]; + std::snprintf(tail, sizeof(tail), "*%02X\r\n", nmea_checksum(body)); + return "$" + body + tail; +} + +/// @brief Largest |angle| an NMEA degrees+minutes field of @p deg_digits holds. +/// @param deg_digits Integer degree digits, which is what distinguishes the two +/// NMEA angle fields: 2 => latitude => 90, 3 => longitude => 180. +/// @return 180.0 when @p deg_digits >= 3 (the longitude field); 90.0 for every +/// other value, which covers the two-digit latitude field and, being +/// the tighter of the two bounds, keeps a nonsensical digit count from +/// widening the accepted range. +/// @note The bound is per-field, not a single global 180: a latitude of, say, +/// 163.55 deg does not exist, and printing it would also widen the field +/// to "16333.1094" — ten characters where GGA specifies nine (ddmm.mmmm). +constexpr double nmea_deg_max(int deg_digits) { + return deg_digits >= 3 ? 180.0 : 90.0; +} + +/// @brief Format a signed angle as an unsigned NMEA degrees+minutes field. +/// @param deg Angle [deg]; the sign is dropped (the hemisphere field carries +/// it). Must already be range-reduced to the field's bound, i.e. +/// |deg| <= nmea_deg_max(deg_digits). +/// @param deg_digits Integer degree digits: 2 for latitude, 3 for longitude. +/// This also selects the legal range checked below (see nmea_deg_max()). +/// @return "ddmm.mmmm" (or "dddmm.mmmm"), minutes zero-padded to 2 integer +/// digits; an empty string (the NMEA "not available" field) when @p deg +/// is not finite or is out of range for the field. +/// @note Minutes are rounded to the printed 4-decimal precision *before* the +/// range check so a value rounding up to 60.0000 carries into degrees +/// instead of emitting an illegal minute field. +/// @note The finiteness/range guard is what keeps the `static_cast` below +/// defined: casting NaN or 1e18 to int is undefined behaviour, and the +/// observed result was a field like "-2147483648 nan" — with embedded +/// spaces inside a comma-delimited field — carrying a valid checksum. +inline std::string nmea_deg_min(double deg, int deg_digits) { + if (!std::isfinite(deg) || std::fabs(deg) > nmea_deg_max(deg_digits)) + return std::string(); + const double a = std::fabs(deg); + int d = static_cast(a); + double m = std::round((a - d) * 60.0 * 1e4) / 1e4; + if (m >= 60.0) { m -= 60.0; d += 1; } + char out[32]; + if (deg_digits >= 3) std::snprintf(out, sizeof(out), "%03d%07.4f", d, m); + else std::snprintf(out, sizeof(out), "%02d%07.4f", d, m); + return std::string(out); +} + +/// @brief Format seconds-of-day as the NMEA UTC time field "hhmmss.ss". +/// @param utc_sod UTC seconds since midnight; values outside [0, 86400) wrap. +/// @return "hhmmss.ss", or an empty field when @p utc_sod is not finite. +/// @note The value is rounded to the printed 1/100 s *first*, then split, so a +/// time in [59.995, 60) carries seconds -> minutes -> hours -> day rather +/// than printing the illegal "125960.00". Formatting "%05.2f" of the +/// unrounded seconds got this wrong about one tick in 12000 at 200 Hz. +inline std::string nmea_utc_field(double utc_sod) { + if (!std::isfinite(utc_sod)) return std::string(); + constexpr double kCentisPerDay = 8640000.0; // 86400 s * 100 + const double sod = utc_sod - std::floor(utc_sod / 86400.0) * 86400.0; + double cs = std::floor(sod * 100.0 + 0.5); // hundredths of a second + if (cs >= kCentisPerDay) cs -= kCentisPerDay; // 23:59:59.999 -> 00:00:00 + const int total = static_cast(cs); + char out[16]; + std::snprintf(out, sizeof(out), "%02d%02d%05.2f", + total / 360000, (total / 6000) % 60, (total % 6000) / 100.0); + return std::string(out); +} + +// WGS84 defining parameters, shared by the geodetic helpers below. +constexpr double kWgs84A = 6378137.0; // semi-major axis [m] +constexpr double kWgs84F = 1.0 / 298.257223563; // flattening +constexpr double kWgs84B = kWgs84A * (1.0 - kWgs84F); // semi-minor axis [m] + +// Half-extents of the WGS84 evolute, the astroid-shaped caustic swept by the +// ellipse's centres of curvature: (a^2-b^2)/a across and (a^2-b^2)/b along the +// spin axis. Both are ~42.8 km, i.e. the whole figure sits around the geocentre, +// ~6335 km below the ellipsoid surface. +constexpr double kEvolutePMax = (kWgs84A * kWgs84A - kWgs84B * kWgs84B) / kWgs84A; // 42697.7 m +constexpr double kEvoluteZMax = (kWgs84A * kWgs84A - kWgs84B * kWgs84B) / kWgs84B; // 42841.3 m + +/// @brief Is a meridional ECEF point at or inside the WGS84 evolute? +/// @param p Cylindrical radius sqrt(x^2 + y^2) of the ECEF point [m]. +/// @param z ECEF z coordinate [m]. +/// @return true when (p, z) lies on or strictly inside the astroid +/// (p/kEvolutePMax)^(2/3) + (|z|/kEvoluteZMax)^(2/3) = 1. +/// @note This is exactly the region where the ECEF->geodetic inverse stops +/// being single-valued: four normals to the ellipse pass through an +/// interior point, so four distinct geodetic latitudes claim it. All four +/// reproduce the input ECEF *exactly*, which is why the round-trip check +/// in enu_to_geodetic() cannot separate them — the region has to be +/// excluded geometrically. On the boundary two of the four roots merge, +/// which is no more a single answer, so the astroid itself is included. +/// @note Non-finite arguments compare false throughout and so report "outside"; +/// enu_to_geodetic() screens those before it gets here. +/// @note Only the meridional (p, z) half-plane is needed: the evolute is a +/// surface of revolution about the spin axis, so longitude does not enter. +inline bool inside_evolute(double p, double z) { + const double u = std::cbrt(std::fabs(p) / kEvolutePMax); + const double v = std::cbrt(std::fabs(z) / kEvoluteZMax); + return u * u + v * v <= 1.0; +} + +/// @brief Local ENU offset -> geodetic: exact closed-form WGS84 inverse. +/// +/// The offsets are interpreted as WGS84 local Cartesian (topocentric) ENU about +/// the datum and inverted in three exact steps — there is no small-angle or +/// flat-Earth approximation anywhere in the chain: +/// @verbatim +/// 1. datum -> ECEF: N0 = a / sqrt(1 - e^2 sin^2 lat0) +/// [x0 y0 z0] = [(N0+h0) cos lat0 cos lon0, +/// (N0+h0) cos lat0 sin lon0, +/// (N0(1-e^2)+h0) sin lat0] +/// 2. ENU -> ECEF: [x y z] = [x0 y0 z0] + R(lat0,lon0)^T [east north up] +/// 3. ECEF -> geodetic: Bowring's closed form + Newton refinement. +/// @endverbatim +/// +/// Accuracy: exact to double-precision rounding. The reference it is measured +/// against is the *exact analytic* ENU->ECEF above, evaluated in 60-digit +/// arithmetic: the result (lat, lon, h) is forward-transformed back to ECEF and +/// compared with that reference point. Worst closure over a 234,234-point grid +/// (datum lat -90..90 incl. both poles, lon -180..180 incl. the antimeridian, +/// datum alt 0/38/8848 m, east/north +-1 m .. +-1e6 m, up -1000..+1e4 m) is +/// **5.2e-9 m**. +/// +/// PROJ is a cross-check, not the reference, because it is the less accurate of +/// the two here. Against `+proj=topocentric` / `+proj=cart` (pyproj 3.5 / +/// PROJ 9.2) this code agrees to **6e-8 m while the resulting ellipsoidal +/// height stays under ~2 km and the datum latitude stays off the poles +/// (|lat0| < 89 deg)** — which covers every offset a ground-vehicle scene +/// produces, including 100 km of horizontal ENU (the tangent-plane rise puts +/// that at ~800 m HAE). Both qualifiers are load-bearing, and in both cases it +/// is PROJ that moves: +/// - Polar datum: the agreement widens to **2e-7 m**, worst measured +/// 1.5832e-07 m at lat0 = -90, lon0 = -180, alt0 = 0, e = -1, n = 1, +/// up = -1000. Against the 60-digit analytic reference this code is +/// 3.3e-10 m there and PROJ is 1.581e-7 m. The excess is confined to a datum +/// exactly on the spin axis: over the same grid lat0 = +-89.9 is 2.8e-9 m. +/// - Above ~2 km HAE, PROJ's `cart` inverse drifts: 1.4e-6 m at 11 km HAE and +/// 3.4e-4 m at 165 km HAE (lat0 60, e = n = -1000 km, up = 10 km), and at +/// those points PROJ differs from the exact analytic reference by that same +/// amount while this code still closes to ~2.6e-9 m. +/// So the PROJ figures are quoted with the range they hold over, and none of +/// them is a bound on this code's error. +/// +/// The equirectangular form this replaced froze the longitude scale at cos(lat0) +/// and was off by 6.75 m at 10 km / 678 m at 100 km from the Seoul datum +/// (lat 37.5665), not the "0.1 m / 100 m" it advertised — that figure only +/// held near the equator, and the error scaled with tan(lat0). +/// +/// What still limits the result is the *definition* of ENU, not this code: +/// - ENU is a flat tangent plane, so a purely horizontal offset d sits +/// ~d^2/(2R) above the ellipsoid: 7.8 mm at 10 m, 7.8 m at 10 km. That +/// appears in alt_m and is the geometrically correct answer for a +/// tangent-plane frame. +/// - Tangent-plane distance is not geodesic arc length (and tangent-plane +/// north is the datum's north, not local grid north), so the ground track +/// differs from a true WGS84 geodesic by ~d^3/(3 R^2): measured 0.008 m at +/// 10 km and 8.2 m at 100 km. That is a property of asking for ENU, not a +/// defect of this inverse. +/// - Altitudes are ellipsoidal (HAE), not orthometric: VDSim has no geoid +/// model, which is why kGgaGeoidSep is a 0.0 placeholder. +/// +/// @param east ENU east offset from the origin [m]. +/// @param north ENU north offset from the origin [m]. +/// @param up ENU up offset from the origin [m]. +/// @param o Geodetic datum the offsets are referenced to. +/// @return `ok = true` with latitude in [-90, 90] deg, longitude in +/// (-180, 180] deg and ellipsoidal altitude [m]; or `ok = false` with +/// all three set to NaN. The postcondition is enforced, not assumed: +/// the result is forward-transformed back to ECEF and rejected unless +/// it reproduces the input point. +/// @note Failure is reported — rather than a nearest-plausible angle — for +/// non-finite inputs and for points at or inside the ellipsoid's evolute +/// (inside_evolute(): the astroid of half-extents 42.70 km across and +/// 42.84 km along the spin axis, around the geocentre), where a point has +/// up to four geodetic latitudes and no basis to prefer one. That test is +/// geometric and covers the whole region, on and off the axis: the +/// round-trip check below cannot do it, because every one of the four +/// candidate latitudes reproduces the input ECEF point exactly. A vehicle +/// 6350 km below the datum is a diverged solver, not a location, so the +/// honest answer is "no fix" — and encode_gga() turns it into exactly +/// that sentence. +inline Geodetic enu_to_geodetic(double east, double north, double up, + const GeodeticOrigin& o) { + constexpr double kA = kWgs84A; // WGS84 semi-major axis [m] + constexpr double kF = kWgs84F; // WGS84 flattening + constexpr double kE2 = kF * (2.0 - kF); // first eccentricity squared + constexpr double kB = kWgs84B; // semi-minor axis [m] + constexpr double kEp2 = (kA * kA - kB * kB) / (kB * kB); // second eccentricity sq. + constexpr double kRad = 0.017453292519943295; // pi/180 + constexpr double kDeg = 57.29577951308232; // 180/pi + + const double lat0 = o.lat_deg * kRad; + const double lon0 = o.lon_deg * kRad; + const double sp = std::sin(lat0), cp = std::cos(lat0); + const double sl = std::sin(lon0), cl = std::cos(lon0); + + const double n0 = kA / std::sqrt(1.0 - kE2 * sp * sp); + const double x0 = (n0 + o.alt_m) * cp * cl; + const double y0 = (n0 + o.alt_m) * cp * sl; + const double z0 = (n0 * (1.0 - kE2) + o.alt_m) * sp; + + // R(lat0,lon0)^T, the transpose of the ECEF->ENU rotation at the datum. + const double x = x0 - sl * east - sp * cl * north + cp * cl * up; + const double y = y0 + cl * east - sp * sl * north + cp * sl * up; + const double z = z0 + cp * north + sp * up; + + Geodetic g; // ok defaults to false, angles NaN + const double p = std::hypot(x, y); + if (!std::isfinite(p) || !std::isfinite(z)) // non-finite input or datum + return g; + // Geometric gate, ahead of everything else: at or inside the evolute the + // inverse has up to four answers that are all exact, so the round-trip + // check below cannot pick one and there is nothing to pick (see + // inside_evolute()). This covers the spin axis, where it reduces to + // |z| <= kEvoluteZMax, and every off-axis interior point alike. + if (inside_evolute(p, z)) + return g; + if (!(p > 1.0e-9)) { // on the spin axis, outside the evolute: pole + g.lat_deg = z >= 0.0 ? 90.0 : -90.0; + g.lon_deg = o.lon_deg - std::floor((o.lon_deg + 180.0) / 360.0) * 360.0; + g.alt_m = std::fabs(z) - kB; + g.ok = true; + return g; + } + // Bowring's parametric-latitude start value: already good to ~0.1 mm. + const double th = std::atan2(z * kA, p * kB); + const double st = std::sin(th), ct = std::cos(th); + double lat = std::atan2(z + kEp2 * kB * st * st * st, + p - kE2 * kA * ct * ct * ct); + // Two Newton steps drive the residual down to double-precision noise, so a + // zero offset round-trips back to the datum to within ~1e-13 deg. + // + // The step is only valid while den = N + h stays above e2*N (~42.7 km): + // below that the x-argument of the atan2() flips sign and the "latitude" + // lands in the second or third quadrant, i.e. |lat| > 90 deg. The evolute + // guard above already rejects that region, so this break is a belt-and- + // braces stop for an iterate that wanders near the boundary; if one ever + // does, the loop keeps Bowring's value and the round-trip check below + // rejects the result. + for (int i = 0; i < 2; ++i) { + const double s1 = std::sin(lat), c1 = std::cos(lat); + const double w = std::sqrt(1.0 - kE2 * s1 * s1); + const double nr = kA / w; + const double h = p * c1 + z * s1 - kA * w; + const double den = nr + h; + if (!(den > kE2 * nr)) break; // degenerate: keep Bowring's value + lat = std::atan2(z, p * (1.0 - kE2 * nr / den)); + } + const double s1 = std::sin(lat), c1 = std::cos(lat); + const double w1 = std::sqrt(1.0 - kE2 * s1 * s1); + const double nr = kA / w1; + const double h = p * c1 + z * s1 - kA * w1; + // Verify rather than assume: forward-transform the answer and require it to + // reproduce the ECEF point that was fed in. This is what makes the + // postcondition real — it catches a non-convergent iterate and a wrong + // branch of the inverse alike, including the |lat| > 90 case above, for + // which cos(lat) < 0 and the reconstructed p comes out negative. + const double pr = (nr + h) * c1; + const double zr = (nr * (1.0 - kE2) + h) * s1; + // 1 mm, plus 1e-11 of the geocentric radius for the cancellation in + // h = p cos + z sin - N w at large offsets. The measured closure over the + // accuracy grid above is 5.2e-9 m, so this is ~5 orders of headroom for a + // valid point while still an order of magnitude tighter than any wrong + // branch of the inverse, which misses by kilometres. + const double tol = 1.0e-3 + 1.0e-11 * std::hypot(p, z); + if (!(std::fabs(pr - p) <= tol) || !(std::fabs(zr - z) <= tol)) + return g; // still the NaN "no fix" default + g.lat_deg = lat * kDeg; + g.lon_deg = std::atan2(y, x) * kDeg; + g.alt_m = h; + g.ok = true; + return g; +} + +/// @brief Current UTC time as seconds since midnight, for the GGA time field. +/// @return Seconds of day in [0, 86400). +/// @note StateFields::timestamp cannot be used: the realtime server stamps it +/// from steady_clock (cosim/realtime_server.cpp now_s()), which is a +/// monotonic uptime, not a wall clock. A GGA sentence carries real UTC, +/// so the sentence is stamped from the system clock at encode time. +inline double utc_seconds_of_day_now() { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + const double secs = std::chrono::duration(now).count(); + return secs - std::floor(secs / 86400.0) * 86400.0; +} + +/// @brief Build a `$GPGGA` sentence for one vehicle state. +/// +/// Position source: the *measured* GNSS channel (StateFields::m_gnss_x/_y), not +/// the truth pose (x/y). A GGA sentence is a receiver output, so a consumer must +/// see the same noise/bias the rest of the sensor stack sees. This is safe with +/// no sensor noise configured: SensorModel::apply() takes an identity path when +/// disabled and still fills gnss_x/gnss_y from the truth position +/// (core/src/sensor_model.cpp), so the field is never left unset — no fallback +/// to x/y is needed. Altitude uses the truth z, because SensorMeas models no +/// GNSS altitude channel. +/// +/// Axis convention: VDSim world frame is ENU right-handed, X east / Y north / +/// Z up (documented at core/include/vdsim/coordinate.hpp:8), so x maps to the +/// ENU east offset and y to the north offset — no swap. +/// +/// Degenerate states: a diverged solver can hand the encoder NaN, infinity, an +/// absurd offset, or an offset that puts the vehicle near/below the Earth's +/// centre where the geodetic inverse has no single answer. Formatting those was +/// undefined behaviour and produced a checksum-valid sentence with spaces +/// embedded inside comma-delimited fields, or a quality-1 fix at an impossible +/// latitude. Instead the sentence degrades the way a real receiver does — +/// quality 0, 00 satellites, 99.9 HDOP and empty position/altitude fields — so +/// it stays well-formed while telling the consumer, explicitly, that there is +/// no fix. There is no path that emits a quality-1 sentence with a field the +/// encoder could not verify. +/// +/// @param s State to encode; m_gnss_x/m_gnss_y are ENU metres from `o`. +/// @param o Per-channel geodetic datum for the ENU->lat/lon projection; +/// latitude must be finite and in the inclusive range [-90, 90] deg, +/// otherwise the sentence reports no fix. +/// @param utc_sod UTC seconds since midnight for the time field (see +/// utc_seconds_of_day_now()); values outside [0,86400) are wrapped. +/// @return Complete sentence including the "*HH\r\n" terminator. +inline std::string encode_gga(const StateFields& s, const GeodeticOrigin& o, + double utc_sod) { + const bool enu_ok = std::isfinite(s.m_gnss_x) && std::isfinite(s.m_gnss_y) && + std::isfinite(s.z) && + std::fabs(s.m_gnss_x) <= kMaxEnuOffsetM && + std::fabs(s.m_gnss_y) <= kMaxEnuOffsetM && + std::fabs(s.z) <= kMaxEnuOffsetM && + std::isfinite(o.lat_deg) && std::isfinite(o.lon_deg) && + std::fabs(o.lat_deg) <= 90.0 && + std::isfinite(o.alt_m); + const Geodetic g = enu_ok ? enu_to_geodetic(s.m_gnss_x, s.m_gnss_y, s.z, o) + : Geodetic{}; // ok = false + // Two independent guards, both feeding the same "no fix" degradation: + // enu_to_geodetic() reports ok = false wherever the closed-form inverse is + // not single-valued (a state below the Earth's surface, say), and + // nmea_deg_min() returns an empty field for anything the field itself + // cannot represent — non-finite, or outside +-90 deg / +-180 deg. + const std::string lat_f = g.ok ? nmea_deg_min(g.lat_deg, 2) : std::string(); + const std::string lon_f = g.ok ? nmea_deg_min(g.lon_deg, 3) : std::string(); + const bool fix_ok = !lat_f.empty() && !lon_f.empty(); + const bool alt_ok = fix_ok && std::isfinite(g.alt_m) && + std::fabs(g.alt_m) <= kMaxGgaAltitudeM; + + char buf[32]; + std::vector f; + f.reserve(15); + f.emplace_back("GPGGA"); + f.emplace_back(nmea_utc_field(utc_sod)); // UTC hhmmss.ss + f.emplace_back(fix_ok ? lat_f : std::string()); // ddmm.mmmm + f.emplace_back(fix_ok ? (g.lat_deg >= 0.0 ? "N" : "S") : ""); + f.emplace_back(fix_ok ? lon_f : std::string()); // dddmm.mmmm + f.emplace_back(fix_ok ? (g.lon_deg >= 0.0 ? "E" : "W") : ""); + std::snprintf(buf, sizeof(buf), "%d", fix_ok ? kGgaFixQuality : 0); + f.emplace_back(buf); // fix quality (0 = invalid) + std::snprintf(buf, sizeof(buf), "%02d", fix_ok ? kGgaSatellites : 0); + f.emplace_back(buf); // satellites in use + std::snprintf(buf, sizeof(buf), "%.1f", fix_ok ? kGgaHdop : 99.9); + f.emplace_back(buf); // HDOP + if (alt_ok) std::snprintf(buf, sizeof(buf), "%.1f", g.alt_m); + f.emplace_back(alt_ok ? std::string(buf) : std::string()); + f.emplace_back("M"); // altitude + unit + std::snprintf(buf, sizeof(buf), "%.1f", kGgaGeoidSep); + f.emplace_back(fix_ok ? std::string(buf) : std::string()); + f.emplace_back("M"); // geoid separation + unit + f.emplace_back(""); // DGPS age (none) + f.emplace_back(""); // DGPS station id (none) + return nmea_sentence(f); +} + +/// @brief Format one double as a JSON number token. +/// @param v Value to format. +/// @param prec Digits after the decimal point. +/// @return The fixed-point text, or the literal `null` when @p v is not finite. +/// @note RFC 8259 has no NaN/Infinity literals: printf's "nan"/"inf" produce a +/// document that neither json.loads nor JSON.parse accepts, and such a +/// frame is short enough that the truncation guard never catches it. +/// `null` is chosen over dropping the frame deliberately — telemetry +/// matters most at the moment the solver diverges, and a parseable object +/// with null fields says "this channel went bad", whereas a missing +/// datagram is indistinguishable from ordinary UDP loss. +inline std::string json_number(double v, int prec) { + if (!std::isfinite(v)) return "null"; + char out[32]; + const int n = std::snprintf(out, sizeof(out), "%.*f", prec, v); + if (n < 0) return "null"; + if (static_cast(n) < sizeof(out)) + return std::string(out, static_cast(n)); + // Enormous-but-finite magnitudes need more digits than the scratch holds; + // they will normally overflow the TX buffer and be dropped by the caller, + // but they must never be reported as a silently truncated number. + std::string big(static_cast(n) + 1, '\0'); + std::snprintf(&big[0], big.size(), "%.*f", prec, v); + big.resize(static_cast(n)); + return big; +} + +/// @brief Format n doubles as the body of a JSON array ("a,b,c,d"). +/// @param v Pointer to the values. +/// @param n Number of values. +/// @param prec Digits after the decimal point, per json_number(). +/// @return Comma-joined number tokens, without the enclosing brackets. +inline std::string json_number_array(const double* v, size_t n, int prec) { + std::string out; + for (size_t i = 0; i < n; ++i) { + if (i) out += ','; + out += json_number(v[i], prec); + } + return out; +} + +/// @brief Encode a state as compact one-line JSON for HTTP/MQTT forwarding. +/// +/// {"id":0,"t":1.23,"x":45.6,"y":-3.2,"yaw":0.04,"vx":15.1,"vy":0.2,"r":0.01, +/// "ax":0.3,"ay":1.2,"Fz":[...],"alpha":[...],"kappa":[...]} +/// +/// Every numeric field goes through json_number(), so a non-finite value is +/// emitted as `null` and the object stays parseable (rationale there). +/// +/// @param buf Output buffer (not NUL-terminated in the returned length). +/// @param buflen Capacity of @p buf in bytes. +/// @param s State to encode. +/// @return Byte count written on success; -1 if @p buf is too small. A partial +/// snprintf result is never reported as success: truncated JSON is +/// invalid JSON, and silently putting it on the wire would corrupt the +/// consumer's stream. +inline int encode_state_json(uint8_t* buf, size_t buflen, const StateFields& s) { + char* p = reinterpret_cast(buf); + const std::string fz = json_number_array(s.Fz, 4, 0); + const std::string alpha = json_number_array(s.slip_angle, 4, 4); + const std::string kappa = json_number_array(s.slip_ratio, 4, 4); + const int n = std::snprintf(p, buflen, + "{\"id\":%u,\"t\":%s," + "\"x\":%s,\"y\":%s,\"yaw\":%s," + "\"vx\":%s,\"vy\":%s,\"r\":%s," + "\"ax\":%s,\"ay\":%s," + "\"Fz\":[%s]," + "\"alpha\":[%s]," + "\"kappa\":[%s]}", + s.vehicle_id, json_number(s.timestamp, 3).c_str(), + json_number(s.x, 2).c_str(), json_number(s.y, 2).c_str(), + json_number(s.yaw, 4).c_str(), + json_number(s.vx, 2).c_str(), json_number(s.vy, 2).c_str(), + json_number(s.yaw_rate, 4).c_str(), + json_number(s.ax, 2).c_str(), json_number(s.ay, 2).c_str(), + fz.c_str(), alpha.c_str(), kappa.c_str()); + // snprintf returns the length it *would* have written: n >= buflen means the + // text was cut short (and n < 0 is an encoding error). + if (n < 0 || static_cast(n) >= buflen) return -1; + return n; +} + +/// @brief Encode a state as a `$GPGGA` sentence into a raw byte buffer. +/// @param buf Output buffer. +/// @param buflen Capacity of @p buf in bytes. +/// @param s State to encode (see encode_gga() for the position-source rationale). +/// @param o Per-channel geodetic datum. +/// @param utc_sod UTC seconds since midnight for the time field. +/// @return Byte count written (excluding any NUL); -1 if @p buf is too small. +inline int encode_state_nmea_gga(uint8_t* buf, size_t buflen, const StateFields& s, + const GeodeticOrigin& o, double utc_sod) { + const std::string sentence = encode_gga(s, o, utc_sod); + if (sentence.size() > buflen) return -1; + std::memcpy(buf, sentence.data(), sentence.size()); + return static_cast(sentence.size()); +} + +} // namespace vdsim::cosim diff --git a/cosim/realtime_server.cpp b/cosim/realtime_server.cpp index 9e771da7..c8ba4186 100644 --- a/cosim/realtime_server.cpp +++ b/cosim/realtime_server.cpp @@ -1,7 +1,9 @@ // vdsim_realtime — real-time UDP co-simulation server (catalog scene or world YAML). // // vdsim_realtime --scene= [options] +#include "comms_templates.hpp" #include "cosim_protocol.hpp" +#include "warn_throttle.hpp" #include "scene_loader.hpp" #include @@ -288,28 +290,6 @@ void fill_state(vdsim::cosim::StateFields& s, const vdsim::SimOutput& o, s.m_steer = o.sensors.steer; s.m_gnss_x = o.sensors.gnss_x; s.m_gnss_y = o.sensors.gnss_y; } -// Encode state to JSON. Compact one-line format for HTTP/MQTT forwarding. -// {"id":0,"t":1.23,"x":45.6,"y":-3.2,"yaw":0.04,"vx":15.1,"vy":0.2,"r":0.01,"ax":0.3,"ay":1.2,"Fz":[4500,4500,3000,3000],"alpha":[...]} -static int encode_state_json(uint8_t* buf, size_t buflen, const vdsim::cosim::StateFields& s) { - char* p = reinterpret_cast(buf); - int n = std::snprintf(p, buflen, - "{\"id\":%u,\"t\":%.3f," - "\"x\":%.2f,\"y\":%.2f,\"yaw\":%.4f," - "\"vx\":%.2f,\"vy\":%.2f,\"r\":%.4f," - "\"ax\":%.2f,\"ay\":%.2f," - "\"Fz\":[%.0f,%.0f,%.0f,%.0f]," - "\"alpha\":[%.4f,%.4f,%.4f,%.4f]," - "\"kappa\":[%.4f,%.4f,%.4f,%.4f]}", - s.vehicle_id, s.timestamp, - s.x, s.y, s.yaw, - s.vx, s.vy, s.yaw_rate, - s.ax, s.ay, - s.Fz[0], s.Fz[1], s.Fz[2], s.Fz[3], - s.slip_angle[0], s.slip_angle[1], s.slip_angle[2], s.slip_angle[3], - s.slip_ratio[0], s.slip_ratio[1], s.slip_ratio[2], s.slip_ratio[3]); - return n > 0 && n < (int)buflen ? n : 0; -} - void touch_sub(std::vector& subs, const sockaddr_in& addr, double t) { for (auto& s : subs) { if (addr_same(s.addr, addr)) { s.last_seen = t; return; } @@ -410,8 +390,13 @@ struct WorldVehicle { // One TX channel resolved to a concrete vehicle + destination sockets (fan-out). struct TxChannel { uint32_t id; - std::string templ {"vds1"}; // vds1 | json (default vds1) + // Canonical encoder name from canonical_tx_template(): vds1 | json | nmea_gga. + std::string templ {"vds1"}; std::vector dests; + vdsim::cosim::GeodeticOrigin origin; // datum for lat/lon templates (nmea_gga) + // Rate limiter for the per-tick encoder warnings below: without it a channel + // whose encoder keeps failing writes one stderr line per tick (200 Hz). + vdsim::cosim::WarnThrottle warn; }; sockaddr_in make_addr(const std::string& ip, int port) { @@ -542,7 +527,7 @@ int run_scene(const std::string& scene_path, int argc, char** argv) { } // Per-vehicle sensors override the scenario-level sensors file, if present. vdsim::SimConfig cfg_v = cfg; - if (spn.sensors) cfg_v.sensors = *spn.sensors; + cfg_v.sensors = vdsim::cosim::effective_sensor_params(cfg.sensors, spn); wv.sim = std::make_unique( std::move(dyn), std::move(gnd), wv.vp, ts, sp_v, cfg_v); // Install any user C++ subsystem-module plugins the resolved vehicle declares. @@ -582,9 +567,11 @@ int run_scene(const std::string& scene_path, int argc, char** argv) { " (v1: .state only), skipping\n", ch.source.c_str()); continue; } - if (ch.templ != "vds1" && ch.templ != "vds1_state") { + const std::string canon = vdsim::cosim::canonical_tx_template(ch.templ); + if (canon.empty()) { std::fprintf(stderr, "[vdsim_realtime] comms: template '%s' unsupported" - " (v1: vds1 only), skipping '%s'\n", ch.templ.c_str(), ch.source.c_str()); + " (vds1 | vds1_state | json | nmea_gga), skipping '%s'\n", + ch.templ.c_str(), ch.source.c_str()); continue; } uint32_t vid = fleet.empty() ? 0u : fleet.front().id; @@ -594,7 +581,7 @@ int run_scene(const std::string& scene_path, int argc, char** argv) { " skipping\n", ch.source.c_str(), vid); continue; } - TxChannel tc; tc.id = vid; tc.templ = ch.templ; + TxChannel tc; tc.id = vid; tc.templ = canon; tc.origin = ch.origin; for (const auto& d : ch.to) tc.dests.push_back(make_addr(d.ip, d.port)); if (!tc.dests.empty()) tx_channels.push_back(std::move(tc)); } @@ -662,7 +649,7 @@ int run_scene(const std::string& scene_path, int argc, char** argv) { auto next = std::chrono::steady_clock::now(); const auto failsafe_dur = std::chrono::duration(cmd_to); const double t_end = world.sim.t_end; // 0 = unlimited - uint8_t out[vdsim::cosim::kStateBytes]; + uint8_t out[vdsim::cosim::kTxBufBytes]; // binary VDS1 frame or a text template while (g_run.load()) { time_scale = read_live_time_scale(scene_path, time_scale); const double period_s = dt / time_scale; @@ -735,19 +722,34 @@ int run_scene(const std::string& scene_path, int argc, char** argv) { g_run.store(false); } if (comms_mode) { - for (const auto& tc : tx_channels) { + for (auto& tc : tx_channels) { const auto* wv = by_id[tc.id]; const auto o = wv->sim->output(); vdsim::cosim::StateFields s; s.seq = seq++; s.timestamp = t; fill_state(s, o, wv->vp.wheel_radius_nominal, wv->id); + // tc.templ is always one of the three values canonical_tx_template() + // can return: the TX resolve loop above drops the channel outright + // when it returns the empty string, so an unknown name never + // reaches this loop. "vds1" is therefore the terminal branch rather + // than a fourth `else if` guarding an unreachable warn/continue. int len = 0; if (tc.templ == "json") - len = encode_state_json(out, sizeof(out), s); - else if (tc.templ == "vds1") - len = vdsim::cosim::encode_state(out, s); - else { - std::fprintf(stderr, "[vdsim_realtime] skip unknown template %s\n", tc.templ.c_str()); + len = vdsim::cosim::encode_state_json(out, sizeof(out), s); + else if (tc.templ == "nmea_gga") + len = vdsim::cosim::encode_state_nmea_gga( + out, sizeof(out), s, tc.origin, + vdsim::cosim::utc_seconds_of_day_now()); + else + len = vdsim::cosim::encode_state(out, s); // "vds1" + if (len < 0) { + // The text encoders never emit a truncated frame: a cut-short + // JSON object / NMEA sentence is unparseable at the consumer. + if (vdsim::cosim::warn_due(t, tc.warn)) + std::fprintf(stderr, "[vdsim_realtime] comms: template %s does not fit" + " the %zu B tx buffer, dropping frame (%llu suppressed so far)\n", + tc.templ.c_str(), sizeof(out), + static_cast(tc.warn.suppressed)); continue; } if (len > 0) { diff --git a/cosim/warn_throttle.hpp b/cosim/warn_throttle.hpp new file mode 100644 index 00000000..5810c63a --- /dev/null +++ b/cosim/warn_throttle.hpp @@ -0,0 +1,41 @@ +// Rate limiter for warnings emitted from a per-tick loop. +// +// The realtime server's TX loop runs at the sim rate (200 Hz in the shipped +// scenes), so any warning printed from inside it repeats 200 times a second per +// channel for as long as the condition holds. Header-only, like the rest of +// cosim/, so the policy is unit-testable without linking the server. +#pragma once + +#include + +namespace vdsim::cosim { + +// Minimum gap between two prints from the same call site [s]. +constexpr double kWarnThrottlePeriodS = 5.0; + +// Per-call-site state for warn_due(). Default-constructed state always lets the +// first warning through, however early in the run it happens. +struct WarnThrottle { + double last_s {-1.0e300}; // clock value of the last print [s] + uint64_t suppressed {0}; // prints skipped since the run started +}; + +/// @brief Decide whether a repeating warning may be printed now. +/// @param now Current clock reading [s]; any monotonic source works (the +/// realtime server passes its steady_clock uptime). +/// @param w In/out throttle state for one call site. On a true result @p w +/// .last_s advances to @p now; on a false result @p w.suppressed is +/// incremented so the next printed line can say how much was hidden. +/// @param period_s Minimum gap between prints [s]. +/// @return true if the caller should print, false if it should stay silent. +/// @note suppressed is a running total for the whole run, not a since-last-print +/// count: the caller prints it *after* this returns true, so resetting it +/// here would always report zero. +inline bool warn_due(double now, WarnThrottle& w, + double period_s = kWarnThrottlePeriodS) { + if (now - w.last_s < period_s) { ++w.suppressed; return false; } + w.last_s = now; + return true; +} + +} // namespace vdsim::cosim diff --git a/cosim/world_scenario.cpp b/cosim/world_scenario.cpp index cd15d008..315f8c94 100644 --- a/cosim/world_scenario.cpp +++ b/cosim/world_scenario.cpp @@ -2,7 +2,14 @@ #include +#include +#include +#include +#include +#include #include +#include +#include namespace vdsim::cosim { @@ -10,36 +17,316 @@ static double node_d(const YAML::Node& n, const char* k, double d) { return n[k] ? n[k].as() : d; } -// Build a SensorParams from a sensors[] list: -// - { type: gnss, noise_std: 0.3 } -// - { type: imu, noise_std: 0.05 } -// - { type: wheel_speed, noise_std: 0.05 } -// - { type: steer, noise_std: 0.002 } -// type maps to the matching SensorNoise field. noise_std, bias, bias_rw per entry. -static vdsim::SensorParams parse_sensors_list(const YAML::Node& list) { - vdsim::SensorParams sp; - sp.enabled = true; +// --------------------------------------------------------------------------- +// agents.vehicle.sensors[] — see docs/CONFIG_GUIDE.md §2.3. +// +// One declaration carries two independent things, and this parser splits them: +// * measurement noise -> vdsim::SensorParams (one per vehicle, per signal group) +// * mount pose + rate -> SceneSensor (one per declared device) +// Accepted shapes for the `sensors:` node on a vehicle entry: +// sensors: [ {...}, {...} ] sequence of entries +// sensors: { enabled: false, seed: 7, list: [..] } suite form (adds enabled/seed) +// sensors: configs/sensors/noisy.yaml a SensorParams file path +// An entry itself has two accepted mount spellings, both of which the repo +// publishes: the canonical `mount: {pos: [x,y,z], rpy: [r,p,y]}` (CONFIG_GUIDE +// §2.3) and the builder's `mount: [x,y,z]` + `yaw: ` (builder/README.md, +// builder/index.html addSensor()). Every other shape, key or type is a mistake +// we refuse to guess at: it throws. docs/CONFIG_GUIDE.md §2.3.1 lists them. +// --------------------------------------------------------------------------- + +// The two halves of one vehicle's sensor declaration, plus whether the +// declaration said anything at all about measurement noise. +struct SensorSuite { + vdsim::SensorParams params; + std::vector mounts; + // True only when the block actually specifies noise: a noise key on an entry + // that has a measurement model, or an explicit `enabled:`/`seed:`, or the + // sensors-file form. A mount-only declaration leaves this false so the + // scenario-level `sensors:` file stays in force for that vehicle instead of + // being silently replaced by an all-zero SensorParams. + bool overrides_noise {false}; +}; + +// Errors name the vehicle and the offending entry, in the same "world scenario: ..." +// shape the rest of this file throws. `where` is "" or "[2] (id=cam)". +[[noreturn]] static void sensor_throw(uint32_t vid, const std::string& where, + const std::string& what) { + throw std::runtime_error("world scenario: vehicle " + std::to_string(vid) + + " sensors" + where + ": " + what); +} + +// Best-effort rendering of a node for an error message. +static std::string node_text(const YAML::Node& n) { + if (n.IsScalar()) return n.Scalar(); + if (n.IsSequence()) return ""; + if (n.IsMap()) return ""; + return ""; +} + +// Range constraint a numeric field must satisfy on top of being finite. Every +// number this parser reads is checked: a NaN noise_std turns every measured +// channel into NaN at runtime, and a negative rate/noise_std is meaningless. +enum class NumRange { Any, NonNegative }; + +static double sensor_num(const YAML::Node& n, uint32_t vid, const std::string& where, + const std::string& key, NumRange range = NumRange::Any) { + double v = 0.0; + try { + v = n.as(); + } catch (const YAML::Exception&) { + sensor_throw(vid, where, key + " must be a number, got '" + node_text(n) + "'"); + } + if (!std::isfinite(v)) + sensor_throw(vid, where, key + " must be finite, got '" + node_text(n) + "'"); + if (range == NumRange::NonNegative && v < 0.0) + sensor_throw(vid, where, key + " must be >= 0, got '" + node_text(n) + "'"); + return v; +} + +// mount.pos / mount.rpy: exactly three finite numbers. +static std::array parse_vec3(const YAML::Node& n, uint32_t vid, + const std::string& where, const std::string& key) { + if (!n.IsSequence() || n.size() != 3) + sensor_throw(vid, where, key + " must be a sequence of 3 numbers, got '" + + node_text(n) + "'"); + std::array v {{0.0, 0.0, 0.0}}; + for (std::size_t i = 0; i < 3; ++i) + v[i] = sensor_num(n[i], vid, where, key + "[" + std::to_string(i) + "]"); + return v; +} + +// mount: { pos: [x,y,z], rpy: [r,p,y] } — canonical form, both keys optional. +// mount: [x, y, z] — the builder's form (builder/README.md): +// position only, heading comes from `yaw:`. +// Sets `rpy_given` when the declaration fixed the orientation here, so the caller +// can reject a `yaw:` that would fight with it. +static void parse_mount(const YAML::Node& m, uint32_t vid, const std::string& where, + SceneSensor& out, bool& rpy_given) { + if (m.IsSequence()) { + out.mount_pos = parse_vec3(m, vid, where, "mount"); + return; + } + if (!m.IsMap()) + sensor_throw(vid, where, "mount must be a map { pos: [x,y,z], rpy: [r,p,y] } " + "or a sequence [x,y,z], got '" + node_text(m) + "'"); + for (const auto& kv : m) { + const std::string k = kv.first.as(); + if (k != "pos" && k != "rpy") + sensor_throw(vid, where, "unknown mount key '" + k + "' (accepted: pos, rpy)"); + } + if (m["pos"].IsDefined()) out.mount_pos = parse_vec3(m["pos"], vid, where, "mount.pos"); + if (m["rpy"].IsDefined()) { + out.mount_rpy = parse_vec3(m["rpy"], vid, where, "mount.rpy"); + rpy_given = true; + } +} + +// params: { fov_deg: 90, ... } — an explicit bag of type-specific numeric knobs. +static void parse_sensor_params_map(const YAML::Node& p, uint32_t vid, + const std::string& where, SceneSensor& out) { + if (!p.IsMap()) + sensor_throw(vid, where, "params must be a map of numbers, got '" + node_text(p) + "'"); + for (const auto& kv : p) { + const std::string k = kv.first.as(); + out.params[k] = sensor_num(kv.second, vid, where, "params." + k); + } +} + +// Route a sensor type onto the SensorParams noise fields it drives. Returns false +// for an unknown type. camera/lidar are declaration-only (mount + rate): the core +// has no measurement model for them, so they map to no noise field at all, and a +// noise key on them is accepted and ignored (the builder emits noise_std for every +// type it can author, see builder/index.html addSensor()). +static bool noise_targets_for(const std::string& type, vdsim::SensorParams& sp, + std::vector& out) { + if (type == "gnss") out = {&sp.gnss_pos, &sp.gnss_vel}; + else if (type == "gnss_pos") out = {&sp.gnss_pos}; + else if (type == "gnss_vel") out = {&sp.gnss_vel}; + else if (type == "imu") out = {&sp.imu_accel, &sp.imu_gyro}; + else if (type == "imu_accel") out = {&sp.imu_accel}; + else if (type == "imu_gyro") out = {&sp.imu_gyro}; + else if (type == "wheel_speed") out = {&sp.wheel_speed}; + else if (type == "steer") out = {&sp.steer}; + else if (type == "camera" || type == "lidar") out.clear(); + else return false; + return true; +} + +static const char* const kSensorTypes = + "gnss, gnss_pos, gnss_vel, imu, imu_accel, imu_gyro, wheel_speed, steer, camera, lidar"; +static const char* const kSensorKeys = + "id, type, mount, yaw, rate, noise_std, bias, bias_rw, params"; + +static bool is_known_sensor_key(const std::string& k) { + return k == "id" || k == "type" || k == "mount" || k == "yaw" || k == "rate" + || k == "noise_std" || k == "bias" || k == "bias_rw" || k == "params"; +} + +// Parse the sequence of entries into both halves of `suite`. +static void parse_sensors_seq(const YAML::Node& list, uint32_t vid, SensorSuite& suite) { + // Uniqueness is enforced only over ids the scene actually wrote. A default id + // (the type name, filled in below) is a display label, not a user promise, so + // two `{type: gnss}` entries — or a `{type: imu}` next to an unrelated + // `{id: imu, ...}` — must not be rejected as a "duplicate". + std::set explicit_ids; + std::size_t index = 0; for (const auto& item : list) { - const std::string type = item["type"] ? item["type"].as() : ""; - double std = item["noise_std"] ? item["noise_std"].as() : 0.0; - double bias = item["bias"] ? item["bias"].as() : 0.0; - double rw = item["bias_rw"] ? item["bias_rw"].as() : 0.0; - auto fill = [&](vdsim::SensorNoise& n) { - n.noise_std = std; n.bias = bias; n.bias_rw = rw; - }; - if (type == "gnss") { fill(sp.gnss_pos); fill(sp.gnss_vel); } - else if (type == "gnss_pos") { fill(sp.gnss_pos); } - else if (type == "gnss_vel") { fill(sp.gnss_vel); } - else if (type == "imu") { fill(sp.imu_accel); fill(sp.imu_gyro); } - else if (type == "imu_accel") { fill(sp.imu_accel); } - else if (type == "imu_gyro") { fill(sp.imu_gyro); } - else if (type == "wheel_speed") { fill(sp.wheel_speed); } - else if (type == "steer") { fill(sp.steer); } - // mount/rate are for future rendering coupling — parsed but not applied here + const std::string at = "[" + std::to_string(index++) + "]"; + if (!item.IsMap()) + sensor_throw(vid, at, "entry must be a map { id, type, mount, rate, ... }, got '" + + node_text(item) + "'"); + + SceneSensor sensor; + if (item["id"].IsDefined()) { + if (!item["id"].IsScalar()) + sensor_throw(vid, at, "id must be a string, got '" + node_text(item["id"]) + "'"); + // Taken as the literal scalar text: `id: true` is the id "true", + // `id: 12` is "12". Quote it to be unambiguous. + sensor.id = item["id"].Scalar(); + } + // Everything past this point can name the sensor in its error message. + const std::string where = sensor.id.empty() ? at : at + " (id=" + sensor.id + ")"; + + for (const auto& kv : item) { + const std::string k = kv.first.as(); + if (!is_known_sensor_key(k)) + sensor_throw(vid, where, "unknown key '" + k + "' (accepted: " + + std::string(kSensorKeys) + ")"); + } + if (!item["type"].IsDefined()) + sensor_throw(vid, where, "missing required key 'type' (accepted: " + + std::string(kSensorTypes) + ")"); + if (!item["type"].IsScalar()) + sensor_throw(vid, where, "type must be one of the type names, got '" + + node_text(item["type"]) + "' (accepted: " + + std::string(kSensorTypes) + ")"); + sensor.type = item["type"].Scalar(); + + std::vector targets; + if (!noise_targets_for(sensor.type, suite.params, targets)) + sensor_throw(vid, where, "unknown type '" + sensor.type + "' (accepted: " + + std::string(kSensorTypes) + ")"); + + if (item["noise_std"].IsDefined() || item["bias"].IsDefined() + || item["bias_rw"].IsDefined()) { + vdsim::SensorNoise n; + if (item["noise_std"].IsDefined()) + n.noise_std = sensor_num(item["noise_std"], vid, where, "noise_std", + NumRange::NonNegative); + if (item["bias"].IsDefined()) + n.bias = sensor_num(item["bias"], vid, where, "bias"); + if (item["bias_rw"].IsDefined()) + n.bias_rw = sensor_num(item["bias_rw"], vid, where, "bias_rw"); + // targets is empty for camera/lidar: the numbers are still validated, + // then dropped, because the core has no measurement model to feed. + for (auto* t : targets) *t = n; + if (!targets.empty()) suite.overrides_noise = true; + } + // No noise key: the entry declares a mount only, so it writes nothing into + // SensorParams. It must not zero a group an earlier entry (or the + // scenario-level file) already set — `{type: gnss, noise_std: 0.5}` followed + // by `{type: gnss_pos}` keeps gnss_pos at 0.5. + + if (!sensor.id.empty() && !explicit_ids.insert(sensor.id).second) + sensor_throw(vid, where, "duplicate sensor id '" + sensor.id + + "'; give each entry a unique id"); + if (sensor.id.empty()) sensor.id = sensor.type; + bool rpy_given = false; + if (item["mount"].IsDefined()) parse_mount(item["mount"], vid, where, sensor, rpy_given); + if (item["yaw"].IsDefined()) { + if (rpy_given) + sensor_throw(vid, where, "yaw and mount.rpy both set the heading; give only one"); + // The builder authors yaw in degrees (builder/index.html: "yaw [deg]"); + // SceneSensor::mount_rpy is radians. + constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + sensor.mount_rpy[2] = sensor_num(item["yaw"], vid, where, "yaw") * kDegToRad; + } + if (item["rate"].IsDefined()) + sensor.rate = sensor_num(item["rate"], vid, where, "rate", NumRange::NonNegative); + if (item["params"].IsDefined()) parse_sensor_params_map(item["params"], vid, where, sensor); + suite.mounts.push_back(std::move(sensor)); + } +} + +// `sensors: ` — a SensorParams yaml. The path is used as written (absolute, +// or relative to the process CWD); if that names no file, it is retried relative to +// the directory of the scene file, which is what a scene-local path means to a user. +static vdsim::SensorParams load_sensors_file(const std::string& raw, uint32_t vid, + const std::string& scene_dir) { + if (raw.empty()) + sensor_throw(vid, "", "empty value; expected a sensors yaml path, a sequence of " + "entries, or a suite map {enabled, seed, list}"); + std::error_code ec; + std::string chosen = raw; + std::string alt; + const std::filesystem::path p(raw); + if (p.is_relative() && !scene_dir.empty()) { + if (!std::filesystem::is_regular_file(p, ec)) { + alt = (std::filesystem::path(scene_dir) / p).lexically_normal().string(); + if (std::filesystem::is_regular_file(alt, ec)) chosen = alt; + } + } + try { + return vdsim::SensorParams::from_yaml(chosen); + } catch (const std::exception& e) { + // Nest the underlying reason: yaml-cpp reports the line and column, and + // dropping it makes a malformed sensors file strictly harder to debug. + std::string msg = "sensors file not loadable: '" + chosen + "'"; + if (!alt.empty() && alt != chosen) msg += " (also tried '" + alt + "')"; + sensor_throw(vid, "", msg + ": " + e.what()); + } +} + +/// @brief Parse one vehicle's sensor declaration in list, suite, or file form. +/// @param sn YAML node stored at the vehicle's `sensors:` key. +/// @param vid Vehicle id included in diagnostic messages. +/// @param scene_dir Directory used to resolve a relative sensor-parameter file. +/// @return Parsed measurement-noise override and mounted sensor declarations. +/// @throws std::runtime_error if the shape, key, type, number, or referenced file +/// violates the scene sensor contract. +static SensorSuite parse_sensors_node(const YAML::Node& sn, uint32_t vid, + const std::string& scene_dir) { + SensorSuite suite; + if (sn.IsSequence()) { + parse_sensors_seq(sn, vid, suite); + if (suite.overrides_noise) suite.params.enabled = true; + } else if (sn.IsMap()) { + for (const auto& kv : sn) { + const std::string k = kv.first.as(); + if (k != "enabled" && k != "seed" && k != "list") + sensor_throw(vid, "", "unknown key '" + k + + "' (the suite form accepts: enabled, seed, list)"); + } + if (!sn["list"].IsDefined() || !sn["list"].IsSequence()) + sensor_throw(vid, "", "suite form needs 'list:' holding a sequence of sensor entries"); + parse_sensors_seq(sn["list"], vid, suite); + if (suite.overrides_noise) suite.params.enabled = true; + // Writing enabled/seed is itself a statement about the noise model, so the + // suite then overrides the scenario-level file even with a mount-only list. + if (sn["enabled"].IsDefined() || sn["seed"].IsDefined()) + suite.overrides_noise = true; + try { + if (sn["enabled"].IsDefined()) suite.params.enabled = sn["enabled"].as(); + if (sn["seed"].IsDefined()) suite.params.seed = sn["seed"].as(); + } catch (const YAML::Exception&) { + sensor_throw(vid, "", "enabled must be a bool and seed a non-negative integer"); + } + } else if (sn.IsScalar()) { + suite.params = load_sensors_file(sn.Scalar(), vid, scene_dir); + suite.overrides_noise = true; + } else { + sensor_throw(vid, "", "must be a sequence of entries, a suite map " + "{enabled, seed, list}, or a sensors yaml path"); } - return sp; + return suite; +} + +vdsim::SensorParams effective_sensor_params(const vdsim::SensorParams& scenario_default, + const VehicleSpawn& v) { + return v.sensors ? *v.sensors : scenario_default; } + // Parse a comms document node ({name, channels: [...]}) into CommsConfig. static CommsConfig parse_comms_node(const YAML::Node& c) { CommsConfig cc; @@ -55,6 +342,13 @@ static CommsConfig parse_comms_node(const YAML::Node& c) { if (ch["template"]) cm.templ = ch["template"].as(); if (ch["listen"] && ch["listen"]["port"]) cm.listen_port = ch["listen"]["port"].as(); + // origin: {lat, lon, alt} — datum for lat/lon templates (nmea_gga). + if (ch["origin"]) { + const auto& og = ch["origin"]; + cm.origin.lat_deg = node_d(og, "lat", 0.0); + cm.origin.lon_deg = node_d(og, "lon", 0.0); + cm.origin.alt_m = node_d(og, "alt", 0.0); + } if (ch["to"] && ch["to"].IsSequence()) { for (const auto& d : ch["to"]) { CommsDest dst; @@ -71,6 +365,9 @@ static CommsConfig parse_comms_node(const YAML::Node& c) { WorldScenario load_world_scenario(const std::string& path) { YAML::Node root = YAML::LoadFile(path); WorldScenario w; + // Fallback base for a relative `sensors:` path on a vehicle entry (see + // load_sensors_file): the directory holding this scene/world file. + const std::string scene_dir = std::filesystem::path(path).parent_path().string(); w.cmd_timeout = node_d(root, "cmd_timeout", 0.1); // Parse sim: {dt, rate, t_end, time_scale, max_substep_dt, max_substeps, stunt_physics} // Fallback to top-level rate/time_scale for backward compat. @@ -145,13 +442,19 @@ WorldScenario load_world_scenario(const std::string& path) { if (v["rear_susp"]) s.rear_susp = v["rear_susp"].as(); if (v["path"]) s.path_yaml = v["path"].as(); if (v["path_lookahead"]) s.path_lookahead = v["path_lookahead"].as(); - // per-vehicle sensors: inline list OR a sensors.yaml file path - if (v["sensors"]) { - const auto& sn = v["sensors"]; - if (sn.IsSequence()) - s.sensors = parse_sensors_list(sn); - else if (sn.IsScalar()) - s.sensors = vdsim::SensorParams::from_yaml(sn.as()); + // per-vehicle sensors: inline list / suite map / a sensors.yaml file path. + // The list form fills both the noise model and the mount declarations. + // IsDefined() is spelled out for the reader, not for yaml-cpp: Node's + // operator bool *is* IsDefined() (yaml-cpp node/node.h), so the two are the + // same test. A bare `sensors:` is a defined null node either way, and it is + // parse_sensors_node's final else branch that reports it. + if (v["sensors"].IsDefined()) { + auto suite = parse_sensors_node(v["sensors"], s.id, scene_dir); + // Only a block that actually specifies noise replaces the scenario-level + // `sensors:` file. A mount-only declaration must not opt the vehicle out + // of the noise model its neighbours run with. + if (suite.overrides_noise) s.sensors = std::move(suite.params); + s.scene_sensors = std::move(suite.mounts); } s.x0 = node_d(v, "x0", 0.0); s.y0 = node_d(v, "y0", 0.0); diff --git a/cosim/world_scenario.hpp b/cosim/world_scenario.hpp index 22a7fb99..a560d462 100644 --- a/cosim/world_scenario.hpp +++ b/cosim/world_scenario.hpp @@ -1,8 +1,11 @@ #pragma once +#include "comms_templates.hpp" #include "vdsim/sensors.hpp" +#include #include +#include #include #include #include @@ -50,6 +53,33 @@ struct StuntConfig { bool rail_guide {false}; }; +/// @brief Mounted sensor declaration parsed from `agents.vehicle.sensors[]`. +/// +/// This is the mounting half of a sensor entry: id, type, body-frame pose, +/// sample rate, and type-specific numeric parameters. The measurement-noise +/// half (`noise_std`, `bias`, and `bias_rw`) is stored separately in +/// VehicleSpawn::sensors because vdsim::SensorParams models signal groups, +/// not individual mounted devices. +/// +/// @note The parser accepts both `mount: {pos: [...], rpy: [...]}` and the +/// builder form `mount: [x,y,z]` plus `yaw` in degrees. +/// @note Mount pose and rate are stored for future render/sensor-frame coupling; +/// no current simulation component consumes them. +struct SceneSensor { + std::string id; // label ("gnss_roof"); defaults to `type` when omitted. Ids the + // scene writes explicitly must be unique within a vehicle; a + // defaulted one is a display label and may repeat. + std::string type; // gnss | imu | wheel_speed | steer | camera | lidar, plus the + // gnss_pos / gnss_vel / imu_accel / imu_gyro sub-signals + std::array mount_pos {{0.0, 0.0, 0.0}}; // body frame [m]: x fwd, y left, z up + std::array mount_rpy {{0.0, 0.0, 0.0}}; // body frame [rad]: roll, pitch, yaw + double rate {0.0}; // declared sample rate [Hz]; 0 = unspecified (core runs at sim rate) + // Type-specific knobs (camera fov_deg, lidar channels, ...) from an explicit + // `params:` sub-map. A map, not fields: the set differs per sensor type and is + // still growing. The well-known fields above stay typed. + std::map params; +}; + struct VehicleSpawn { uint32_t id {0}; std::string vehicle_yaml; @@ -66,8 +96,16 @@ struct VehicleSpawn { // "internal": built-in controller (v1 = speed-hold cruise at vx0). std::string control {"external"}; // Per-vehicle sensor noise spec (from agents.vehicle.sensors[] in the scene). - // When set, overrides the scenario-level RoadConfig.sensors file path. + // When set, overrides the scenario-level RoadConfig.sensors file path. Set only + // when the declaration actually specifies noise (a noise key on an entry that has + // a measurement model, an explicit enabled/seed, or the sensors-file form): a + // mount-only `sensors:` block leaves this empty so the vehicle keeps the + // scenario-level file rather than silently running clean beside its neighbours. std::optional sensors; + // Mount/rate declarations from that same agents.vehicle.sensors[] list, one + // entry per declared device. Empty when the vehicle uses the sensors-file form + // (a sensors yaml carries noise only, no mount pose). Stored, not yet consumed. + std::vector scene_sensors; // Per-vehicle reference path for internal path-follow controller. // path_yaml: trajectory file ({points:[[x,y,vx],...]} — pure path data, no controller params) // path_lookahead: pure-pursuit lookahead distance [m] (controller param, not part of trajectory) @@ -89,6 +127,10 @@ struct CommsChannel { std::string templ {"vds1"}; // template/규약: vds1 | vds1_cmd | json | nmea_gga int listen_port {0}; // rx: udp port to bind std::vector to; // tx: fan-out destinations + // Geodetic datum the sim's ENU metres are referenced to, for templates that + // emit lat/lon (nmea_gga). YAML: origin: {lat: 37.5, lon: 127.0, alt: 38.0}. + // Optional; defaults to (0,0,0) so existing scenes keep working unchanged. + GeodeticOrigin origin; }; struct CommsConfig { @@ -108,4 +150,13 @@ struct WorldScenario { WorldScenario load_world_scenario(const std::string& path); +/// @brief Select the measurement-noise parameters in force for one vehicle. +/// @param scenario_default Parameters loaded from the scenario-level sensors file. +/// @param v Vehicle whose optional sensor declaration may override the default. +/// @return The per-vehicle parameters when present; otherwise @p scenario_default. +/// @note A mount-only declaration leaves VehicleSpawn::sensors empty and therefore +/// preserves the scenario-level default. +vdsim::SensorParams effective_sensor_params(const vdsim::SensorParams& scenario_default, + const VehicleSpawn& v); + } // namespace vdsim::cosim diff --git a/docs/AC_FAST_LANE_IMPORTER.md b/docs/AC_FAST_LANE_IMPORTER.md new file mode 100644 index 00000000..0b4dadea --- /dev/null +++ b/docs/AC_FAST_LANE_IMPORTER.md @@ -0,0 +1,66 @@ +# Assetto Corsa fast_lane.ai M1 importer + +The M1 importer converts a user-owned Assetto Corsa v7 AI spline into a +VDSim reference-path bundle. It does not parse KN5, modify the simulation core, +or redistribute Assetto Corsa data. + +## Coordinate contract + +- Output frame: ISO 8855, X forward, Y left, Z up. +- Length: metres. Angle metadata: radians. +- The importer never guesses the source axes or origin. +- `--transform-json` is mandatory and must contain a finite, orthonormal 4x4 + homogeneous transform. Handedness-changing orthonormal transforms are valid. +- `--closed-loop` is mandatory because the supported binary payload has no + reliable loop flag. +- Missing widths are errors unless the user explicitly passes + `--fallback-half-width-m`; every replacement is recorded as a warning. + +## CLI + +```bash +python3 tools/ac_fast_lane_importer.py /owned-track/ai/fast_lane.ai \ + --out-dir /tmp/track-m1 \ + --track-id track/layout \ + --transform-json /owned-track/source_to_vdsim.json \ + --closed-loop yes +``` + +The JSON file is either a 4x4 array or an object with the +`transform_source_to_vdsim` key. The command writes: + +- `reference_path.csv`: ordered `index,s_m,x_m,y_m,z_m,left_width_m,right_width_m`. +- `manifest.yaml`: source hash, units, frame, exact transform, loop state and + importer version. +- `report.md`: point-count/length comparison, source/output bounding boxes and + explicit warnings. + +## Python API + +```python +from tools.ac_fast_lane_importer import import_fast_lane + +result = import_fast_lane( + "fast_lane.ai", + "track-m1", + source_track_id="track/layout", + transform_source_to_vdsim=matrix_4x4, + closed_loop=True, +) +``` + +`reference_path.csv` can be read directly and its XY columns attached as a +trace overlay object with `kind: path2d`; the importer performs no resampling. + +## Supported input and legal boundary + +- Supported: standard little-endian v7 AI spline with 20-byte base points, + 72-byte detail points and an ACTools-compatible `HasGrid` tail. +- `HasGrid=1` bounds, sampling metadata and variable item/subitem/value arrays + are fully consumed and validated, then ignored because M1 only needs the path. +- Grid flag values outside 0/1, negative or overflowing counts, truncated + payloads and trailing bytes fail closed. +- Unsupported versions and damaged point/detail sequences fail immediately. +- Run locally against the user's own Assetto Corsa installation. +- Do not commit or redistribute original AI files or converted track bundles. +- The repository fixture is fully synthetic and CC0-1.0. diff --git a/docs/CATALOG_AND_PHYSICS.md b/docs/CATALOG_AND_PHYSICS.md index 4a2d8294..88220a6e 100644 --- a/docs/CATALOG_AND_PHYSICS.md +++ b/docs/CATALOG_AND_PHYSICS.md @@ -39,6 +39,27 @@ L2 is **not** “3-DOF Ackermann”: the chassis is planar 3-DOF (x, y, yaw); th ladder name **Seven-DOF** counts four wheel-spin states. Ackermann steering is one feature of L2, not the level name. +## Computational cost per step + +**Benchmark** (ioniq5_awd, dt=5e-4, MF2002 tire, flat ground): + +| Level | Model | ms/step | Realtime factor | +|-------|-------|--------:|-----------------| +| **K** | Kinematic bicycle | 0.0005 | ~1000× | +| **L1** | Bicycle | 0.0162 | ~31× | +| **L2** | Seven-DOF | 0.0313 | ~16× | +| **L3** | Fourteen-DOF | 0.0317 | ~16× | +| **L4** | Hardpoint multibody | 0.0321 | ~15× | +| **L5** | Free-3D stunt | 0.0354 | ~14× | + +**Notes:** +- **K** has no tire slip / dynamics; ~30× faster than L1. +- **L2–L4** are dominated by MF2002 tire evaluation; L3/L4 nearly identical (L4 hardpoint kinematics not yet wired to suspension geometry in this test). +- **L5** adds 3D orientation + strut DAE (~10% slower than L4). +- Measurement includes `SimSession.tick()` overhead (ground contact, actuator lag, sensor filtering). + +**Tool:** `python3 tools/bench_dynamics_levels.py` (Python L0–L4) + `build/bin/vdsim_bench_levels` (C++ L5). + ## Running a scene ```bash diff --git a/docs/CONFIG_GUIDE.md b/docs/CONFIG_GUIDE.md index 9d952b46..69bf9c09 100644 --- a/docs/CONFIG_GUIDE.md +++ b/docs/CONFIG_GUIDE.md @@ -165,7 +165,7 @@ agents: # (= 현재 fleet) vehicle: # 차량 = 부품(blueprint+parts) + 센서 blueprint: vehicle.sedan_comfort # 🟢 완성 레시피(모든 슬롯 채움) parts: { tire: tire.sport_grip } # 🟢 그 위 슬롯 override(diff). 없으면 blueprint 원본 - sensors: # 🔴 차량 안 (mount = 위치+자세 pose) + sensors: # 🟢 파싱/저장 (mount = 위치+자세 pose) — 아래 §2.3.1 - { id: gnss, type: gnss, mount: { pos: [1.4,0,1.0], rpy: [0,0,0] }, rate: 10 } - { id: cam, type: camera, mount: { pos: [1.6,0,1.2], rpy: [0,-0.05,0] }, rate: 30 } spawn: { x: -15, y: -1.5, yaw: 0, vx: 12 } # 🟢 (현재 x0/y0/z0/yaw0/vx0) @@ -181,13 +181,109 @@ control 의미(런타임 무관): action 을 sim core 의 `SimSession::set_input blueprint vs parts: blueprint = base 레시피, parts = 그 위 슬롯 patch. materialize 가 `blueprint + parts → resolved vehicle.yaml/tire.yaml` 로 합친다. -> 현재 구현은 평평한 `fleet:` (agent=blueprint+pose+control)이고, 위 `agents.vehicle.sensors` -> / `path`(trajectory 파일) / `map` / scenario-level `sim`·`comms` 는 단계적으로 추가(🔴). -> 계층 자체는 위가 확정. +> 현재 구현은 평평한 `fleet:` (agent=blueprint+pose+control)이고, 위 `path`(trajectory 파일) +> / `map` / scenario-level `sim`·`comms` 는 단계적으로 추가(🔴). `agents.vehicle.sensors` +> 는 파서까지 들어왔다(§2.3.1). 계층 자체는 위가 확정. + +### 2.3.1 `sensors:` — 에이전트 센서 선언 🟢(YAML→WorldScenario 파서) / 🔴(렌더 커플링) + +`fleet[].sensors`(= `agents.vehicle.sensors`)를 `cosim/world_scenario.cpp` 가 파싱해 +`VehicleSpawn` 에 싣는다. 한 항목이 서로 다른 두 가지를 동시에 나른다: + +- **노이즈** `noise_std`/`bias`/`bias_rw` → `VehicleSpawn::sensors` (`vdsim::SensorParams`, + 신호 그룹 단위) → 시뮬레이션에 **실제로 적용된다**. +- **장착** `id`/`type`/`mount{pos,rpy}`(또는 `mount:[x,y,z]`+`yaw`)/`rate`/`params` → `VehicleSpawn::scene_sensors` + (`SceneSensor`) → **파싱해서 저장하는 데까지만**. mount pose 를 읽는 소비자는 아직 없고 + (향후 렌더/센서 프레임 커플링 🔴), 지금은 시뮬 결과에 아무 영향을 주지 않는다. + +세 가지 형태를 받는다: + +```yaml +sensors: # (1) 목록형 — §2.3 의 정식 형태 +- { id: gnss_roof, type: gnss, mount: { pos: [0.2,0,1.42], rpy: [0,0,0] }, rate: 10, noise_std: 0.3 } +- { id: cam_front, type: camera, mount: { pos: [1.6,0,1.2], rpy: [0,-0.05,0] }, rate: 30, params: { fov_deg: 90 } } +- { id: gnss_b, type: gnss, mount: [0.2,0,1.42], yaw: 15, rate: 10, noise_std: 0.3 } # builder 표기 + +sensors: # (2) suite 형 — enabled/seed 까지 지정 + enabled: false + seed: 42 + list: + - { id: gnss, type: gnss, mount: { pos: [1.4,0,1.0] }, rate: 10 } + +sensors: configs/sensors/noisy.yaml # (3) SensorParams 파일 경로 (노이즈만, mount 없음) +``` + +- `type`: `gnss`·`gnss_pos`·`gnss_vel`·`imu`·`imu_accel`·`imu_gyro`·`wheel_speed`·`steer` + ·`camera`·`lidar`. `camera`/`lidar` 는 코어에 계측 모델이 없어 **장착 선언 전용**이다. + 노이즈 키를 줘도 값 검증만 하고 **버린다**(먹일 계측 모델이 없다) — builder 는 타입과 + 무관하게 항상 `noise_std` 를 붙이므로 에러로 막지 않는다. +- `mount` 는 두 표기를 모두 받는다: + - `mount: { pos: [x,y,z], rpy: [r,p,y] }` — 정식 형태. `pos` [m] = 차체 좌표(x 전방 / + y 좌 / z 상), `rpy` [rad] = roll·pitch·yaw. 둘 다 선택, 생략 시 0. + - `mount: [x,y,z]` + `yaw: ` — builder 가 내보내는 형태(`builder/README.md`, + `builder/index.html` 의 `addSensor()`). `yaw` 는 **도(deg)** 단위이고 라디안으로 바꿔 + `mount_rpy[2]` 에 들어간다. `yaw` 와 `mount.rpy` 를 함께 주면 에러(같은 각을 두 번 지정). +- `rate` [Hz] 는 선언값일 뿐 다운샘플링은 아직 없다(0/생략 = 미지정). +- `params:` 는 타입별 확장 knob 을 담는 숫자 map(`fov_deg` 등). 역시 저장만 한다. +- `id` 생략 시 `type` 이 id 가 된다. **명시한** id 는 한 차량 안에서 유일해야 하고 중복은 에러다. + 생략해서 자동으로 붙은 id 는 표시용 라벨이라 겹쳐도 된다(`{type: gnss}` 두 개 OK, 뒤 항목의 + 노이즈가 이긴다). `id` 는 스칼라 원문 그대로다 — `id: true` → `"true"`, `id: 12` → `"12"`. +- **덮어쓰기 규칙**: 차량별 `sensors` 블록이 시나리오 레벨 `sensors:`(파일 경로)를 덮는 것은 + 그 블록이 **노이즈를 실제로 말했을 때뿐**이다 — 계측 모델이 있는 타입의 + `noise_std`/`bias`/`bias_rw`, suite 형의 `enabled`/`seed`, 또는 (3) 파일 형. mount·id·rate· + params 만 선언한 블록은 `scene_sensors` 만 채우고 `VehicleSpawn::sensors` 를 비워 두므로 그 + 차량은 시나리오 레벨 파일을 그대로 쓴다. 즉 **mount 를 하나 적었다는 이유로 옆 차와 다른 + (깨끗한) 센서로 도는 일이 없다**. (`effective_sensor_params()`, `cosim/world_scenario.hpp`) +- **그룹이 겹칠 때**: 노이즈 키가 없는 항목은 `SensorParams` 에 아무것도 쓰지 않는다. 그래서 + `[{type: gnss, noise_std: 0.5}, {type: gnss_pos}]` → `gnss_pos = gnss_vel = 0.5`, + `[{type: imu, noise_std: 0.05}, {type: imu_gyro, mount: {...}}]` → `imu_accel = imu_gyro = 0.05`. + 노이즈 키가 **있는** 항목은 예전처럼 그룹을 통째로 덮는다: + `[{type: gnss, noise_std: 0.5}, {type: gnss_pos, noise_std: 0.1}]` → `gnss_pos=0.1`, `gnss_vel=0.5`. + (⚠️ 시뮬 출력이 바뀌는 지점이다. 예전에는 뒤의 장착 전용 항목이 기본값 0 을 써 넣어 + `gnss_pos=0` / `imu_gyro=0` 이 됐다. 장착 선언이 노이즈를 지우던 쪽이 버그라 고쳤다.) +- 숫자 검증: 모든 숫자는 유한해야 한다(NaN/Inf 거부 — `mount` 성분, `rate`, `noise_std`, + `bias`, `bias_rw`, `params.*`). 추가로 `rate >= 0`, `noise_std >= 0`. `bias`/`bias_rw` 는 + 부호 제한 없이 유한하기만 하면 된다. +- (3) 의 상대경로는 **먼저 프로세스 CWD 기준**으로 찾고, 없으면 **scene 파일이 있는 디렉터리 + 기준**으로 다시 찾는다(그래서 리포 루트에서 돌리는 `configs/sensors/noisy.yaml` 도, scene 옆에 + 둔 `my_sensors.yaml` 도 둘 다 열린다). 둘 다 실패하면 시도한 경로와 yaml-cpp 의 원인 + 메시지(파일·줄·열)를 함께 던진다. +- **잘못된 입력은 전부 hard error**: 조용히 무시하지 않고 차량 번호와 문제 키를 지목해 throw + 한다. 정확히 무엇이 막히는지는 바로 아래 §2.3.1.1. +- 동작 샘플: `configs/scenes/two_vehicle_race.yaml` (0번 차량이 mount pose 4개를 선언. 노이즈 + 키가 없으므로 시나리오 레벨 설정을 덮지 않는다). + +#### 2.3.1.1 ⚠️ breaking change — 예전에 통과하던 `sensors:` 입력이 이제 로드를 막는다 + +`feat(cosim): parse scene sensor mount pose/id/rate` 이전에는 `sensors:` 항목의 잘못된 값이 +대부분 **조용히 무시**됐다(그리고 `fleet:` 카탈로그 scene 에서는 `sensors:` 가 world YAML 로 +전달조차 되지 않아 아무 효과가 없었다). 지금은 아래 입력이 **로드를 중단시킨다**. 기존 scene +이 여기 해당하면 고쳐야 한다: + +| 입력 | 이전 | 지금 | +|---|---|---| +| `type` 누락 | 그 항목을 무시 | throw `missing required key 'type'` | +| `type` 이 스칼라가 아님 (`type: [gnss]`) | 무시 | throw `type must be one of the type names` | +| 모르는 `type` (`radar`) | 무시 | throw `unknown type` | +| 모르는 항목 키 (`noise_stdd`) | 무시 | throw `unknown key` | +| 모르는 `mount` 키 (`position`) | 무시 | throw `unknown mount key` | +| `mount` 가 map 도 3원소 시퀀스도 아님 / `pos`·`rpy` 원소가 3개가 아님 | 무시 | throw | +| `yaw` 와 `mount.rpy` 동시 지정 | (해당 없음) | throw | +| 숫자 자리에 숫자가 아닌 값 (`rate: fast`) | yaml-cpp 예외 또는 무시 | throw(키 지목) | +| NaN/Inf 숫자, `rate < 0`, `noise_std < 0` | 그대로 통과(런타임 NaN/음수) | throw | +| 값이 빈 `sensors:` / `sensors: ''` | 센서 없음 취급 | throw | +| `list:` 없는 map 형 `sensors:` / suite 형의 모르는 키 | 센서 없음 취급 | throw | +| 항목이 map 이 아님 (`- gnss`) | 무시 | throw `entry must be a map` | +| 시퀀스·map·스칼라 어느 것도 아닌 `sensors:` | 무시 | throw | +| **명시한** id 중복 | 마지막 항목이 이김 | throw `duplicate sensor id` | +| 읽을 수 없는 sensors 파일 경로 | yaml-cpp 예외 | throw(원인 메시지 중첩) | + +반대로 **에러가 아닌 것**(허용): builder 표기 `mount: [x,y,z]` + `yaw:`, `camera`/`lidar` 의 +`noise_std`/`bias`/`bias_rw`(검증 후 무시), `id` 를 생략한 같은 `type` 의 중복 선언. --- -## 3. 실시간 통신 설정 — comms/*.yaml 🟢(vds1) / 🔴(json·nmea·sensor) +## 3. 실시간 통신 설정 — comms/*.yaml 🟢(vds1·json·nmea_gga) / 🔴(sensor.*) realtime 모드에서 **데이터가 어디로 흐르는지** 선언. comms 는 **시나리오 단위** — scene 의 `comms: ` 키가 `configs/comms/.yaml` 를 가리킨다(에이전트별이 아니라 시나리오가 소유). @@ -199,9 +295,16 @@ channels: - source: 0.state # .state | ego.state(=첫 차) template: vds1 # 🟢 VDS1 바이너리 (cosim/cosim_protocol.hpp) to: [ {ip: 127.0.0.1, port: 7100}, {ip: 127.0.0.1, port: 7101} ] - - source: 0.sensor.gnss # 어느 센서 → 소비자 (🔴 미구현, 경고 후 skip) + - source: 0.state # 🟢 JSON 한 줄 (HTTP/MQTT 브리지용) + template: json + to: [ {ip: 127.0.0.1, port: 7100} ] + - source: 0.state # 🟢 NMEA 0183 $GPGGA ("*HH\r\n" 종단) template: nmea_gga + origin: { lat: 37.5665, lon: 126.9780, alt: 38.0 } # ENU 미터의 측지 원점(선택, 기본 0/0/0) to: [ {ip: 10.0.0.5, port: 9001} ] + - source: 0.sensor.gnss # 센서 소스 지정 (🔴 미구현, 경고 후 skip) + template: nmea_gga + to: [ {ip: 10.0.0.5, port: 9002} ] # 수신 RX: 포트에서 listen → 규약 → (vehicle_id 헤더로) 어느 제어 (fan-in) - direction: in template: vds1_cmd # 🟢 패킷 헤더 vehicle_id 로 대상 에이전트 선택 @@ -211,7 +314,28 @@ channels: - **TX(송신)** = `source`(`.state`) → `template`(규약) → `to`(ip:port, fan-out). - **RX(수신)** = `direction: in` + `listen.port` → `template` → 제어 입력 (fan-in). 어느 에이전트인지는 VDS1 cmd 헤더의 `vehicle_id` 로 결정. -**구현 상태**: `vdsim_realtime` 가 comms.yaml 을 실행하는 라우터(🟢) — TX `.state`+`vds1` fan-out, RX `vds1_cmd` listen-port fan-in 동작. comms 키가 없으면 레거시(단일 cmd-in/state-out CLI 플래그)로 fallback. `json`/`nmea_gga` template 및 `sensor.*` source 는 아직 미구현(🔴) — 경고 찍고 skip. +**구현 상태**: `vdsim_realtime` 가 comms.yaml 을 실행하는 라우터(🟢) — TX `.state` 를 `vds1`(=`vds1_state`) / `json` / `nmea_gga` 로 fan-out, RX `vds1_cmd` listen-port fan-in 동작. comms 키가 없으면 레거시(단일 cmd-in/state-out CLI 플래그)로 fallback. 그 외 template 이름과 `sensor.*` source 는 아직 미구현(🔴) — 경고 찍고 skip. + +`nmea_gga` 세부(구현: `cosim/comms_templates.hpp`, 샘플: `configs/comms/json_nmea.yaml`): +- **origin**: 채널별 선택 필드 `origin: {lat, lon, alt}`. 시뮬 ENU 미터(X east / Y north / Z up, `core/include/vdsim/coordinate.hpp`)의 측지 기준점. 미지정 시 (0, 0, 0). +- **변환**: WGS84 정밀 폐형해 — ENU → ECEF (origin ECEF + ENU 회전행렬) → geodetic (Bowring + Newton). 근사가 아니므로 **위도 의존성이 없다**. lat∈[-90,90], lon∈(-180,180] 로 항상 정규화된다. + - **정확도 기준은 해석적(analytic) ECEF**: 결과 (lat, lon, h) 를 다시 ECEF 로 정변환해 60자리 정밀도로 계산한 기준점과 비교했을 때, 234,234 점 격자(datum lat −90~90 극점 포함, lon −180~180 날짜변경선 포함, datum alt 0/38/8848 m, east/north ±1 m~±1e6 m, up −1000~+1e4 m)에서 **최대 5.2e-9 m**. + - **PROJ 는 기준이 아니라 교차검증**이다. `+proj=topocentric` / `+proj=cart` (pyproj 3.5 / PROJ 9.2) 와는 **결과 타원체고(HAE)가 약 2 km 이하이고 datum 위도가 극점을 벗어난(|lat0| < 89°) 범위에서 6e-8 m 이내**로 일치한다 — 수평 ENU 100 km(접평면 상승으로 HAE ≈ 800 m)까지 포함해 지상 차량 시나리오 전 구간이 여기 들어간다. 두 단서는 모두 필요하고, 벌어지는 쪽은 두 경우 모두 **PROJ** 다. + - **datum 이 극점(|lat0| ≥ 89°)**: 일치 범위가 **2e-7 m** 로 넓어진다(실측 최댓값 1.5832e-07 m — lat0 −90, lon0 −180, alt0 0, e −1, n 1, up −1000). 같은 점에서 60자리 해석적 기준 대비 이 구현은 3.3e-10 m, PROJ 는 1.581e-7 m 다. 초과분은 datum 이 정확히 자전축 위(±90°)일 때만 나타난다 — 동일 격자에서 lat0 ±89.9° 는 2.8e-9 m. + - **HAE 2 km 초과**: PROJ 의 `cart` 역변환이 벌어진다. HAE 11 km 에서 1.4e-6 m, HAE 165 km(lat0 60, e=n=−1000 km, up=10 km)에서 3.4e-4 m 이며, 같은 점에서 PROJ 자신이 해석적 기준과 동일한 크기로 어긋난다(이 구현은 여전히 ~2.6e-9 m). + 따라서 PROJ 수치는 성립 범위(고도·위도)를 반드시 함께 적어야 하고, 이 구현의 오차 상한이 아니다. + - 직전 버전은 등거리원통(equirectangular) 근사(`lon = lon0 + east/(N·cos lat0)`)로, 서울 datum(lat 37.5665) 기준 10 km 에서 6.75 m / 100 km 에서 678 m 오차였다 — 문서에는 0.1 m / 100 m 로 적혀 있었으나 그것은 적도 근처에서만 성립하는 값이다. + - 남은 오차는 구현이 아니라 ENU **정의** 자체다: 접평면이므로 수평 거리 d 에서 타원체 위로 ~d²/(2R) 만큼 뜨고(10 km 에서 7.8 m, GGA 고도에 반영됨), 진짜 측지선 대비 ~d³/(3R²) 차이가 남는다(10 km 에서 8 mm, 100 km 에서 8.2 m). 고도는 타원체고(HAE)이며 geoid 모델은 없다. +- **비정상 상태 처리**: 솔버가 발산해 NaN/Inf, 비상식적으로 큰 ENU 오프셋(|offset| > 1e8 m), 또는 **역변환이 유일하지 않은 지점**(지구 중심 근처 — 타원체 evolute 위/내부)이 들어오면, 실제 수신기처럼 **no-fix 문장**(quality 0, 00 satellites, 99.9 HDOP, 빈 위치/고도 필드)을 내보낸다. quality 1 문장에 검증되지 않은 필드가 실리는 경로는 없다. + - 폐형해는 결과를 ECEF 로 되돌려 입력점과 대조한 뒤에만 성공(`Geodetic::ok`)으로 보고한다. 실패 시 lat/lon/alt 는 NaN 이며, 기본 생성된 `Geodetic` 역시 `ok=false` + NaN 이다. + - **evolute 판정은 기하학적 판정**이다(`inside_evolute()`): 자전축 위뿐 아니라 축을 벗어난 점도 포함해, 자오면 좌표 (p, z) 에 대해 astroid 부등식 `(p/42697.7)^(2/3) + (|z|/42841.3)^(2/3) ≤ 1` 을 직접 평가한다. 왕복(round-trip) 검사로는 이 영역을 걸러낼 수 없다 — 내부의 점 하나에 대해 **네 개의 위도 해가 모두 입력 ECEF 를 정확히 재현**하기 때문이다. 실제로 예전에는 축 위(p ≈ 0)에서만 판정해서, 예컨대 datum (0,0,0) + `m_gnss_x=-1595.7, m_gnss_y=23172.3, z=-6380182.2`(ECEF 원점에서 23.3 km)가 **지구 중심의 quality 1 · 위성 12 · HDOP 0.9 문장**으로 나갔다. 이 영역은 타원체면에서 약 6335 km 아래(지오센터 반경 42.9 km 이내)라 정상 차량 상태와는 무관하다. + - NMEA 각도 필드 범위 검사는 필드별 상한을 쓴다 — 위도(2자리) 90°, 경도(3자리) 180°. 예전에는 둘 다 180° 로 검사해서, 예컨대 datum 서울 + `z = -6.35e6` m 같은 발산 상태가 위도 −163.55° 를 `16333.1094`(GGA 규격 9자 자리에 10자)로 실어 **체크섬이 맞는 quality 1 문장**으로 나갔다. + - 과거에는 `static_cast(NaN)` UB 로 필드 안에 공백이 들어간 문장이 체크섬까지 맞은 채 전송됐다. +- **위치 소스**: 측정 GNSS(`m_gnss_x/_y`). GGA 는 수신기 출력이므로 센서 노이즈가 그대로 반영된다. 노이즈 미설정 시에도 `SensorModel` 이 항등 경로로 truth 를 채우므로 값이 비지 않는다. 고도만 truth `z`(GNSS 고도 채널 모델 없음). +- **UTC 필드**: 시스템 wall clock (STATE 의 `timestamp` 는 steady_clock 기반 monotonic 이라 UTC 가 아님). 초는 출력 정밀도(1/100 s)로 **먼저 반올림한 뒤** 초→분→시→날 자리올림을 하므로 `125960.00` 같은 불법 시각은 나오지 않는다. +- **satellites / HDOP / geoid separation**: 위성·DOP 모델이 없어 고정 placeholder(12 / 0.9 / 0.0). + +`json` 세부: 모든 숫자 필드는 유한값이 아니면 RFC 8259 의 `null` 로 출력된다(`nan`/`inf` 는 JSON 이 아니라 `json.loads`·`JSON.parse` 모두 거부한다). 발산 시 프레임을 버리지 않고 `null` 로 신호하는 이유는, 조용히 사라진 데이터그램은 단순 UDP 손실과 구분되지 않기 때문이다. --- @@ -226,6 +350,8 @@ channels: | 시뮬 세팅(rate/dt·time_scale·integrator·duration) | 🟡 | §1.5, 현재 분산 / 통합 `sim:` 블록은 🔴 | | per-agent `control{internal|external}` | 🟡 | §2.2 (이번 추가; internal=speed-hold v1) | | comms.yaml 라우터 — vds1 TX fan-out / RX fan-in | 🟢 | §3, `vdsim_realtime` (scene `comms:` 참조) | -| comms json·nmea_gga template / sensor.* source | 🔴 | §3, 경고 후 skip | +| comms json·nmea_gga TX template (+ 채널별 `origin`) | 🟢 | §3, `configs/comms/json_nmea.yaml` | +| comms sensor.* source | 🔴 | §3, 경고 후 skip | | scene `path`(trajectory 파일)/`maneuver`/`run{mode}` | 🔴 | §2.3, SIM_CONFIG_ARCH | -| agent `vehicle.sensors`(mount pose) / map-path CTE | 🔴 | §2.3, SIM_CONFIG_ARCH §7 | +| agent `vehicle.sensors` 파서(mount pose→`WorldScenario`, 키·타입·유한성·범위 검증 포함) | 🟢 | §2.3.1 / §2.3.1.1, `cosim/world_scenario.cpp` | +| mount pose 를 쓰는 렌더/센서프레임 커플링 / map-path CTE | 🔴 | §2.3.1, SIM_CONFIG_ARCH §7 | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index a958ea7e..df7b4d1b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -57,7 +57,270 @@ **Verify:** `cmake --build build -j && cd build && ctest --output-on-failure` -## 0. 2026-06-14 세션 인수인계 (fresh 세션은 여기부터) + +## 0. 2026-06-25 Product freeze boundary — ACTIVE + +**GUI v3:** preserved @ P0 parity gap 0 (commit `feat(gui): v3 preserve`). +**FROZEN** — no further GUI polish/features until ≥2 scenario-authoring segment adoption failures cite GUI absence. + +**Plant API (P1 wedge):** open — thesis / external MPC (`vdsim_plant`, CmdL1 path). + +**Wheel CI (P0-1):** `.github/workflows/wheels.yml` — tag `v*` → cibuildwheel + PyPI (trusted publisher). +**HOLD:** no `v0.7.0` tag / PyPI publish / main merge until PO decision-4 gate (PO signal). +Pre-publish: `python3 tools/verify_wheel_packaging.py wheelhouse/*.whl` (1 public `.tir`, 0 measured). + +**P0-2 quickstart:** `examples/quickstart.py` + `vdsim-quickstart`; README cites **~6 s cold** (measured, not "5 min"). +**P0-3 demo:** `examples/demo_grip_loss.py` → `docs/assets/demo_grip_loss.gif` (headless, pillow). +**P0-4 VALIDATION:** currency v0.5.1+, **404/404 ctest**; README validation summary; tire cross-check wording (not product parity). ISO numbers unchanged (IsoBaseline gated). + +## 0. 2026-06-23 CmdL1 per-wheel torque binding fix — DONE + +**Bug:** `def_readwrite` on `std::array` returned a copy — `cmd.motor_torque[i]=x` was silent no-op. +**Fix:** `MutableWheelArray` in-place view (`__getitem__`/`__setitem__`) + `vdsim_plant._fx_to_cmdl1` whole-array assign. +**Tests:** `test_cmdl1_element_torque_assign`, `test_longitudinal_fx_accel` in `test_vla_plant.py`. + +## 0. 2026-06-23 CO-00 scenario template gallery — DONE + +**Templates:** Empty · Figure-8 · Straight · Skidpad (R=40 circle) +**API:** `POST /api/setup/template` · Scenario inspector 2×2 chip gallery +**E2E:** template chip `button.tpl` locator (accessible name includes hint text) + +## 0. 2026-06-23 GUI v3 sim time + RN-02 + SH-05 — DONE + +**Sim time:** stream `t` = plant timestamp − first frame (not wall epoch) +**RN-02:** Run Autopilot/Manual + manbar (↑↓←→ + touch) → `/api/fleet` + `/api/manual` +**SH-05:** `HelpLink` → mkdocs theory pages (Scenario road/path, Run pose/tire) + +## 0. 2026-06-22 GUI v3 Run/Scenario polish — DONE + +**RN-06:** per-wheel force triad (Fx/Fy/Fz arrows) in `RunViewport` · Forces toggle +**Run telemetry:** sim time `t` · per-wheel Fz/Fx/Fy/κ/α table +**CO-06:** waypoint summary table (downsampled) in Scenario inspector +**Docs:** concept art README — require live capture pairing for thesis figures + +## 0. 2026-06-22 GUI v3 visual P0 — DONE + +**P0 fixes (vs concept art review):** +- Scenario map: path from `setup.path_pts` (no 0-point race) · grid axis labels (m) · waypoint # in edit mode +- Run idle: preview vehicle mesh + path at fleet spawn (`RunViewport` setup_mode) +- Play → Run navigation hardened · capture flow waits sync+start · `06-run-sim-*.png` + +**Review:** [`docs/evidence/review/GUI_V3_VISUAL_REVIEW.md`](evidence/review/GUI_V3_VISUAL_REVIEW.md) · captures `docs/evidence/review/gui-captures/` + +Regenerate: `python3 tools/gui_v3_capture.py` · eval: `python3 tools/gui_v3_eval_bundle.py` + +## 0. 2026-06-22 GUI v3 scaffold + Scenario API — DONE + +**Concept art:** [`docs/design/assets/`](design/assets/) (6 PNG + README). + +**Scaffold:** `gui/v3/` — Svelte 5 + Vite · `#/scenario` default. + +**Scenario mode (wired):** `GET/POST /api/setup`, `/api/path`, `/api/fleet` (load scenario), +`/api/scenario/save`, map canvas (path + fleet drag), road μ/grade/bank, path preset, ▶ Play → `/api/run/start`. + +**Still placeholder:** — (L4 hardpoint editor shipped in Build mode). + +**Scenario path edit (CO-07):** map toolbar **Edit path** · drag ● · click add · Del · Esc. + +**Analyze mode (v3.0-beta):** `/api/compare` · ISO maneuvers · yaw-rate overlay · Δ% table · CSV export. + +**Build mode (v3.0-beta):** assembly · blueprint **Export/Register** · **Parts library** · Δ stats · linkage SVG · **L4 hardpoint editor** (pick/drag y–z, K&C preview, save to gui_custom). + +**Run mode (v3.0-beta):** `/api/stream` SSE · Three.js fleet mesh · chase/orbit/top · telemetry · Stop/Reset. + +```bash +python3 gui/server.py --port 8095 +cd gui/v3 && npm run build # or npm run dev +python3 tools/gui_v3_eval_bundle.py # headless eval → docs/evidence/review/ +# http://localhost:8095/v3/#/scenario +``` + +## 0. 2026-06-21 GUI v3 planning — DONE (superseded by scaffold above) + +Direction locked: Scenario-first · Run-second · Svelte 5 + Vite ([`GUI_V3_ADR.md`](design/GUI_V3_ADR.md)). + +## 0. 2026-06-20 thesis freeze + core → main port — DONE + +**Tag:** `vdsim-thesis-beta` @ `6623f55` (VLA plant beta before core commits). + +**Commits on thesis (not all on main):** +- `c4b5c0a` — core (#3–9, per-wheel tire) → **cherry-picked to `main`** (`38b1955` + `5cdca4e`) +- `e5fb2c6` — VDSimPlant wiring → **thesis-only** + +**Develop on `main` going forward;** thesis = VLA delivery / hotfix only. + +## 0. 2026-06-18 pre-main cleanup (agreed items 3–5,7–9) — DONE + +**Shipped (1/6/2 deferred — interface discussion pending):** +- **#4 `tire(wheel)`** — `IVehicleDynamics::tire(int)` on L1/L2/L5; pybind `dyn.tire(wheel=0)` +- **#5 steer lag** — `vp.steer_deadtime_s` → `ActuatorModel` (direct path uses actuator, removed `direct_steer_lag_`) +- **#8 ladder lowering** — `ladder_lowering.hpp` centralises 1500×5 / 600 / 4000 (ISO frozen) +- **#7 friction canonical** — `create_friction_patch_ground` → wide-y polygon + `PolygonFrictionGround`; `make_friction_ground()` + `FrictionMapConfig` +- **#3 session factory** — `make_direct_control_session(vp, TireSetup, sp, opts)`; `make_vla_plant_session` = shim +- **#9 thin plant** — `SimOutput` wheel GT fields; `vdsim_plant._obs_from_output(sess.output())` + +**Verify:** `cmake --build build -j && cd build && ctest --output-on-failure && +python3 tests/scripts/test_vla_plant.py` + +## 0. 2026-06-18 per-wheel tire (advanced) — DONE + +**Shipped:** FL/FR/RL/RR can each use different `TireParams` on L1/L2/L3/L5 force loops. +Unified (one `TireParams`) and per-axle `(front, rear)` constructors remain backward +compatible. Catalog optional slots: `tire_rear` (RL+RR when no corner slot), +`tire_fr` / `tire_rl` / `tire_rr` → resolved `tire_*.yaml` in fleet row. + +**Deliverables:** +- **`TireSetup`** (`params.hpp`) — `wheel[4]` + `for_wheel` / `for_axle`; corner ctor + + `from_corner_yaml_paths` +- **Dynamics:** 4× `ITireModel` via `init_wheel_tire_models` (was per-axle) +- **Resolver / materialize:** corner yaml paths on `ResolvedVehicle` + fleet row keys +- **Cosim:** `VehicleSpawn.tire_{fr,rl,rr}_yaml` + `load_tire_setup` +- **pybind:** `wheel`, `front`/`rear` properties, `from_corner_yaml_paths` +- **Tests:** `PerWheelTire.L2FrontLeftRightDifferentGrip` (+ existing `PerAxleTire`) + +**Verify:** `cmake --build build -j && cd build && ctest -R 'PerAxleTire|PerWheelTire' --output-on-failure` + +## 0. 2026-06-18 per-axle tire (core) — DONE + +**Shipped:** front/rear axle can use different `TireParams` (MF96/MF2002/LuGre/belt) on +L1/L2/L3/L5 force loops. Single `TireParams` still duplicates to all corners (backward +compatible). Catalog optional `tire_rear` slot → `tire_rear.yaml` in resolved fleet. + +**Deliverables:** +- **`TireSetup`** — per-axle ctor / `from_yaml_paths`; shim `initialize(vp, TireParams, sp)` +- **Dynamics:** per-wheel `ITireModel` routing (same axle shares params unless corner slot set) +- **Resolver:** `tire_rear` optional blueprint slot; `ResolvedVehicle.tire_rear_yaml` +- **Cosim:** `VehicleSpawn.tire_rear_yaml` → `TireSetup::from_yaml_paths` +- **pybind:** `TireSetup`, `initialize_setup` +- **Tests:** `PerAxleTire.L2FrontRearDifferentGrip`, `PerAxleTire.L1UnifiedMatchesSingleTireParam` + +**Verify:** `ctest -R PerAxleTire --output-on-failure` + +## 0. 2026-06-18 VLA plant P2 — smooth 1-D friction blend + steer lag docs — DONE + +**Shipped (customer P2, `docs/PLANT_CUSTOMER_FEEDBACK.md`):** +- **1-D `friction_map` smooth transition** — `FrictionPatchGround` linear blend over **1.0 m** + before `x0` and after `x1`; outside `x < x0−1` / `x > x1+1` → `base_mu`; overlapping + patches → min μ (same rule as 2-D polygon). +- **`VehicleParams.steer_deadtime_s`** — 1st-order steer lag (τ) on VLA direct-control path + (`SimSession::tick` when `direct_control_path`); default 0 = instantaneous ZOH δ. +- **Docs:** `docs/VDSIM_PLANT_TUTORIAL.md` §6a (1-D blend), §7 (steer lag manual workflow). + +**Deliverables:** +- `core/src/contact_providers.cpp` — `FrictionPatchGround` blend +- `core/src/sim_session.cpp` — direct-path steer lag from `vp.steer_deadtime_s` +- **Test:** `VlaPlant.FrictionPatchSmoothTransition` + +**Verify:** `cmake --build build -j && cd build && ctest -R VlaPlant --output-on-failure` + +## 0. 2026-06-18 VLA plant P1 — roll/pitch obs + 2D polygon friction map — DONE + +**Shipped (customer P1, `docs/PLANT_CUSTOMER_FEEDBACK.md`):** +- **`obs["roll"]`, `obs["pitch"]`** [rad] — quasi-static angles from `roll_angle_qs()` / + `pitch_angle_qs()` (L2–L5; L1→0). Already on `IVehicleDynamics` + pybind; wired in + `python/vdsim_plant.py` `_obs()`. +- **`friction_map_2d`** — `[{"polygon": [(x,y),...], "mu": 0.5}, ...]` on `VDSimPlant` + (mutually exclusive with 1-D `friction_map`). C++ `PolygonFrictionGround` + + `create_polygon_friction_ground()` in `contact_providers.cpp`: per-wheel world (x,y), + point-to-polygon boundary distance, linear blend to `base_mu` over **1.0 m** global; + overlapping patches → min μ. + +**Deliverables:** +- `core/include/vdsim/interfaces.hpp` — `PolygonMuPatch`, factory decl +- `core/src/contact_providers.cpp` — `PolygonFrictionGround` +- `python/bindings.cpp` — `PolygonMuPatch`, `create_polygon_friction_ground`, + `make_vla_plant_session(..., poly_patches, blend_distance)` +- `python/vdsim_plant.py` — `friction_map_2d`, roll/pitch in obs +- **Tests:** `VlaPlant.PolygonFrictionInsideAndOutside`, `PolygonFrictionBlendZone`; + Python `test_roll_pitch_in_obs`, `test_polygon_friction_map_2d` +- **Docs:** `docs/VDSIM_PLANT_TUTORIAL.md` §6b (2D polygon), §4 roll/pitch + +**Verify:** `cmake --build build -j && cd build && ctest -R VlaPlant --output-on-failure && +python3 tests/scripts/test_vla_plant.py` + +## 0. 2026-06-18 Dynamics ladder bench L0–L5 — DONE + +**Shipped:** `tools/bench_dynamics_levels.py` + `examples/bench_dynamics_levels.cpp` +(`vdsim_bench_levels`) — ioniq5_awd, dt=5e-4, 500 step, SimSession.tick() ms/step. + +| level | ms/step (AILAB-12, Release) | +|-------|----------------------------:| +| L0 kinematic | 0.0005 | +| L1 bicycle | 0.0162 | +| L2 seven_dof | 0.0313 | +| L3 fourteen_dof | 0.0317 | +| L4 fourteen_dof_kinematic | 0.0321 | +| L5 Free3D stunt | 0.0354 | + +L0–L4 via pybind `make_sim_session`; L5 via C++ binary (not in pybind session factory). + +**Run:** `cmake --build build -j && python3 tools/bench_dynamics_levels.py` + +## 0. 2026-06-18 VLA plant per-wheel peak slip (alpha_peak, kappa_peak) — DONE + +**Shipped:** per-wheel MF pure-slip peak positions (`alpha_peak` [rad], `kappa_peak` [-]) at +current Fz/load — raw GT for rising/peak/sliding (drift) classification vs current slip. + +**Deliverables:** +- **`mf_peak_slip(B,C,E)`** in `interfaces.hpp` (Newton on MF peak equation) +- **`ITireModel::Output/Wrench::alpha_peak/kappa_peak`** + `IVehicleDynamics::wheel_alpha_peak/kappa_peak()` (Ld2) +- MF2002 / MF96 / linear `compute()` fill; `evaluate()` + Ld2 step propagate +- **`python/bindings.cpp`** + **`python/vdsim_plant.py`** `wheel[i]["alpha_peak"]`, `["kappa_peak"]` +- **Test** `VlaPlant.PeakSlipMatchesNumericalArgmax` + +**Verify:** `cmake --build build -j && cd build && ctest -R VlaPlant --output-on-failure` + +## 0a. 2026-06-18 VLA plant realized peak mu (useGT option A) — DONE + +**Shipped:** per-wheel `mu_peak` (load-dependent MF realized peak coefficient, force/Fz) exposed +alongside existing contact `wheel.mu`. Friction-circle GT metric +`useGT = ||[Fx,Fy]|| / (mu_peak*Fz)` is now <= 1 by construction; contact `mu` preserved. + +**Deliverables:** +- **`ITireModel::Output/Wrench::mu_peak`** + `IVehicleDynamics::wheel_mu_peak()` (Ld2) +- MF2002 / MF96 / linear `compute()` fill `mu_peak`; `evaluate()` propagates to wrench +- **`python/bindings.cpp`** + **`python/vdsim_plant.py`** `wheel[i]["mu_peak"]` +- **Test** `VlaPlant.RealizedPeakMuBoundsFrictionCircle` + +**Verify:** `cmake --build build -j && cd build && ctest -R VlaPlant --output-on-failure` + +## 0b. 2026-06-18 VLA plant tyre → MF2002 (T-IVb) — DONE + +**Shipped:** Ioniq5-class VLA plant now uses **MF2002** (load-dependent `PKY*` cornering stiffness + +`PDY2` peak-μ load sensitivity) instead of MF96. + +**Deliverables:** +- **`configs/parts/tire/ioniq5_pac2002.tir`** — public-synthetic Pacejka-2002 (gitignore-excepted) +- **`configs/parts/tire/ioniq5_pac2002.yaml`** — `backend: mf2002` + `tir_path` +- **`configs/vehicles/ioniq5_awd.yaml`** — `tire_yaml: parts/tire/ioniq5_pac2002.yaml` +- MF96 yaml **`ioniq5_pacejka.yaml`** kept (not deleted) +- **`python/vdsim_plant.py`** — resolves relative `tir_path` from configs root +- **`python/bindings.cpp`** — exposes `TireParams.backend` / `tir_path` to Python +- **Tests** (`test_vla_plant.cpp`): `DryHandlingMatchesLinearBicycleRealMf2002Tire`, + `Mf2002LoadSensitivityConcaveStiffnessAndMu`, `Mf2002FrictionPatchHalvesPeakGrip`, + `CombinedBrakeTurnGripLossOnPatchMf2002` (+ legacy MF96 inline tests unchanged) + +**Verify:** `cmake --build build -j && cd build && ctest -R VlaPlant --output-on-failure` + +**Spec:** `docs/design/VLA_THESIS_PLANT.md` + +## 0c. 2026-06-18 VLA thesis plant (T-IV) — DONE + +**Shipped:** `vdsim_plant.VDSimPlant` — first external client plant API for closed-loop MPC. + +**Deliverables:** +- **#0** Ld2 native `CmdL1` torque path (Fx→per-wheel τ, bypass throttle map) + `plant_path` kinematic-blend bypass +- **#1** `create_friction_patch_ground(base_mu, [(x0,x1,mu)...])` + pybind +- **#2** `wheel_mu()` on Ld2 + pybind +- **#3** `configs/vehicles/ioniq5_awd.yaml` + `configs/parts/tire/ioniq5_pacejka.yaml` +- **#4** `python/vdsim_plant.py` on `vdsim_lab` / `make_vla_plant_session` (direct CmdL1, ZOH two-tier dt) +- **#5** smokes: `tests/scripts/test_vla_plant.py` + `examples/vla_plant_demo.py` + `python/VDSIM_PLANT_README.md` + +**Verify:** `cmake --build build -j && cd build && ctest -R vla_plant --output-on-failure` (headline #2 brake+turn on μ=0.5 patch). + +**Spec:** `docs/design/VLA_THESIS_PLANT.md` + +## 0d. 2026-06-14 세션 인수인계 (archive) **목표였던 것:** 멀티차량 비교(헤드리스+GUI) + 다나와식 차량조립 UI + 외부 AI 디자인 리뷰 반영. 완료. @@ -76,6 +339,10 @@ 3. **P2** — ~~Chrono KC `gen_kc_reference` API fill-in~~ **done** — CSV committed, `ChronoKcParity` active. 4. **P3** — v0.5.2 GUI: terrain Play **or** `.tir` import; **GUI v2** framework decision. 5. **Thesis** — VLA hotfix only on `VDSim-Thesis`; plant → main PR after client sign-off. +**다음 할 일 후보 (우선순위 순):** +1. **GUI 재아키텍처 (전략 결정 후)** — 외부 AI 리뷰 4/10 "프로토타입" 판정. 큰 항목: dockable/resizable 패널 + 모달 제거(분할화면), 글로벌 모드 nav(Setup-Sim-Analyze), 전사 디자인 시스템. **stdlib http.server + 손코딩 app.js 의 한계 — React/Svelte + 디자인 패스(Figma) 결정이 선행.** 지금 찔끔 하지 말 것. +2. **#158 GUI 멀티차량 3D render** — preset picker 로 spawn 은 됨, 3D 뷰 N대 동시 표시(VDS1 vehicle_id demux) 남음. +3. cosmetic — part 라벨 "Fsk Formula chassis body" 잔재, Session tune 탭 라벨. **주의·함정:** - retax2 **breaking** — 옛 `chassis.*`/`susp.*` part id, `front_susp_kin` 슬롯 제거됨. diff --git a/docs/HANDOFF_L5_MBD.md b/docs/HANDOFF_L5_MBD.md index 4ec1bc00..d2913a5b 100644 --- a/docs/HANDOFF_L5_MBD.md +++ b/docs/HANDOFF_L5_MBD.md @@ -20,7 +20,18 @@ green**. main HEAD = `7d77754`. 태그 `v0.6.0` 푸시됨. **미push = `7d77754` - v0.6.0 tag, ROADMAP 갱신, superseded 설계문서 3개 배너. **미완 / 다음 할 일**: -0. **[최우선 — VLA thesis plant = 첫 고객/제품, Cursor 위임 in-flight]** spec(최종·자립형): +0. **[최우선 — VLA thesis plant = 첫 고객 = 첫 BETA TESTER. 브랜치 `VDSim-Thesis`]** + main(`34db50c`)은 stable 고정 — plant 작업은 전부 `VDSim-Thesis` 브랜치. **베타이므로 + 현 구현을 못박지 말 것**: Cursor 1차 구현 → **개발 후 장단점 평가 → 개선(iterate)** 단계가 + 파이프라인의 일부(스펙의 product-quality bar는 "v0 beta, 평가 후 다듬는다" 기준으로 적용, + contract 영구 freeze 아님). main 머지는 베타 평가·polish 통과 후에만. + **베타 v0 구현+검증 DONE (VDSim-Thesis @ 5e2b33e, 390/390 green)**: Cursor 1차 구현 후 + Claude iterate — (1) **실결함 수정**: direct-L1 brake가 상수라 ω<0 역회전(κ→−31) → tanh(ω/eps) + 부호정규화 → locked κ≈−1. (2) acceptance 6종 강화/추가: throttle-bypass·headline combined + grip-loss(circle 불변식+real slip+brake-no-reverse+차량 grip-loss)·GT consistency(ΣFy↔m·ay)· + **dry==linear bicycle ratio 0.963(4%, BCD→Ca 캘리브 검증)**·perf 23ms/5s·friction-patch. + 남은 polish: #7 README obs frame/단위, 그리고 **thesis 실사용 피드백→다음 iterate**(진짜 베타). + spec(자립형): `docs/design/VLA_THESIS_PLANT.md` (#0=Fx→torque physical κ 확정 + 2-tier dt + product quality bar 포함). Cursor headless 위임함(background) — 출력 `/tmp/claude-1000/.../b8v49i1fc.output`. **다음 세션 필수 = Cursor 산출물 검증·리뷰·폴리시 게이트** (Claude 책임): `git diff` 리뷰 + diff --git a/docs/PLANT_CUSTOMER_FEEDBACK.md b/docs/PLANT_CUSTOMER_FEEDBACK.md new file mode 100644 index 00000000..510af9b3 --- /dev/null +++ b/docs/PLANT_CUSTOMER_FEEDBACK.md @@ -0,0 +1,98 @@ +# VDSim Plant — 첫 고객 피드백 (VLA thesis closed-loop plant) + +작성 2026-06-18. 작성자 = VLA 졸업논문(T-IV) 세션 = `~/vla_design/`. VDSimPlant(Ld2 7DOF/MF2002, BETA #1)의 +**첫 외부 고객**으로서, anticipatory chance-MPC의 P1 gate를 VDSim 위에서 돌린 실사용 경험 기록. + +사용 규모(이 피드백의 근거): 한 세션에서 **수백 회 closed-loop rollout** — V0 14~32 m/s, 저-μ patch +0.20~0.50, patch 공간길이 22~90 m, single lane-change, det/frozen/antic 3-controller 비교. 한계·불가능 +영역까지 의도적으로 몰아넣음(drift=\|α\|/α_peak 최대 5까지). + +--- + +## 1. 아주 좋았던 것 (그대로 유지/홍보 권장) + +### (a) ★ `mu_peak` / `alpha_peak` / `kappa_peak` = 이 plant의 킬러 신호 +- 처음엔 `wheel.mu`(contact μ)만으로 friction-circle usage를 봤는데, GT plant에선 타이어가 용량을 못 넘어 + 포화되니 usage가 의미 없이 ≤1로 막혀 **sliding을 못 보여줬다**(useGT>1 혼란). 이걸 피드백하니 + **load-dependent realized peak(`mu_peak`) + pure-slip peak(`alpha_peak`/`kappa_peak`)를 추가**해줬다. +- 이게 결정적이었다. **drift = \|α\|/α_peak (>1 = past-peak = 실제 sliding)** 가 내 핵심 안전 metric이 됐다. + contact μ 기반 usage보다 물리적으로 정확하고 baseline 실패를 선명히 잡는다. +- **요청**: 이 신호들을 "한계주행 평가용 권장 safety metric"으로 튜토리얼/문서에 명시 홍보해라. 다른 제어 + 연구자도 똑같이 contact-μ usage의 함정에 빠질 것이다. + +### (b) load-dependent peak μ = "과도 demand → 진짜 슬립"의 물리 +- 하중 이전으로 로딩 외륜 peak μ가 contact μ보다 낮아진다(예: contact 0.4 → realized peak 0.379, Fz≈8826N). + 이게 **컨트롤러 belief(bicycle, contact μ)와 plant 현실 사이 gap**을 만들어, deterministic baseline이 + "belief상 안전한데 실제 슬립"하는 물리를 생성한다. **Python bicycle soft-clamp로는 절대 못 얻는 fidelity**고, + reviewer가 "장난감 plant"라 못 깐다. thesis의 σ_Fz(grip 불확실성)→chance-constraint 서사의 plant-side 근거. +- 확인 요청: 이 realized-peak 하강이 MF2002 .tir 의도와 정합하는지(내가 이 메커니즘에 결과를 의존하므로). + +### (c) drop-in API = 진짜 plant swap +- `VDSimPlant(config, base_mu, friction_map, control_dt, substep_dt)` / `reset(state0)` / `step([δ,Fx]) → obs` + 가 요청 스펙과 거의 일치. MPC(acados) 한 줄 안 고치고 numpy bicycle plant를 통째 교체했다. 인터페이스 설계 훌륭. +- obs dict(차량 + per-wheel Fx/Fy/Fz/α/κ/μ/mu_peak/α_peak)가 closed-loop·figure에 바로 물린다. + +### (d) 결정론 + 속도 + 수치 강건성 +- RNG 없음 → 완전 재현. 5s rollout이 수십 ms → V0×μ×patch×mode 수백 run sweep을 가볍게 돌렸다. +- **한계 너머로 학대해도 안 터졌다**: μ=0.20, V0=28, drift 5까지 가도 NaN/발산 없이 coherent GT 유지. + 2-tier substep(control ZOH + sub-ms 내부적분)이 stiff한 wheel/torque 동역학을 잘 잡는다는 강한 방증. +- 부수효과: **plant를 GT로 신뢰할 수 있어서**, 내 컨트롤러의 조향 chatter가 plant가 아니라 *내 solver* + 문제임을 격리할 수 있었다. control 연구에 plant가 믿을 수 있는 ground-truth라는 게 정확히 필요한 가치다. + +--- + +## 2. 마찰점 (고치면 다음 고객이 편함) + +### (a) ★ Python 버전 — 가장 큰 진입장벽 +- delivery note의 셋업(`cmake -B build`)이 만든 모듈이 **py3.8**이라 내 conda env(`vla`, py3.11)서 `import vdsim` + 실패. 결국 수동 재빌드 필요: + `cmake -B build_vla -DVDSIM_BUILD_PYTHON=ON -DPython3_EXECUTABLE= -Dpybind11_DIR=...` + - `VDSIM_BUILD_PYTHON` 기본 OFF라 그냥 빌드하면 python 모듈이 안 나오는 것도 헷갈렸다. + - **요청**: (1) py3.11 wheel 동봉 또는 (2) delivery note/튜토리얼 맨 앞에 "conda/py3.x 재빌드 레시피"를 + 굵게. 단일 `build/`가 시스템 py3.8에 묶여 있으면 대부분의 ML/제어 사용자(conda py3.10+)가 막힌다. + +### (b) acados와 `LD_LIBRARY_PATH` 충돌 +- acados env(`source ~/opt/acados/env.sh`)와 같이 쓰면 import 시 lib 충돌 → `env -u LD_LIBRARY_PATH bash -c '...'` + 로 우회해야 했다. acados+VDSim은 MPC plant 조합의 전형이니 튜토리얼에 한 줄 경고 권장. + +### (c) `Fx_total` → per-wheel 분배 불투명 +- 제어입력 Fx가 "CG force intent"인데 wheel torque로 어떻게 분배되는지(drive_split, 제동 분배) obs/문서에서 + 바로 안 보였다. 내부적으론 (ii) Fx_total→wheel torque(native MF96 combined slip)로 정리된 걸로 이해하나, + **적용된 per-wheel 종력 분배를 obs나 doc에 명시**하면 종방향 거동 디버깅이 쉬워진다. + +--- + +## 3. 기능 요청 (우선순위) + +- **P1 — split-μ / per-wheel friction map**: 현재 `friction_map=[(x0,x1,μ)]` = 종방향 X-band(좌우 동일). + split-μ(좌우 다른 μ)나 (x,y) map이면 split-μ 제동/안정성 시나리오가 가능(ABS/ESC급 연구 수요). +- **P1 — obs에 roll angle / 하중이전 성분 노출**: per-wheel Fz는 있으나 roll angle φ가 obs에 없다. 7DOF/14DOF + 검증과 σ_Fz(load-transfer) 서사에 직접 쓰인다. +- **P2 — 선택적 1차 조향 actuator lag**: 현재 δ는 road-wheel ZOH 직접(=MPC baseline엔 정확히 맞음). robustness + 연구용으로 `steer_tau` 옵션(끄면 현 동작)이 있으면 실차 격차 연구 가능. +- **P2 — μ patch의 smooth 전이 옵션**: 현재 box(계단) μ. 제어기 입장에서 계단 μ는 수치적으로 거칠 수 있어, + tanh 전이 폭(transition width) 인자가 있으면 좋다. +- **★ P1 — 표준 시험기동 track 프리셋**: ISO 3888-1/3888-2(moose), sine-with-dwell, FMVSS 126 등 표준 maneuver를 + cone-gate 정의(차폭 b 연동: 구간길이 12/13.5/11/12.5/12 m, 폭 1.1b+0.25 / b+1 / 1.3b+0.25, 횡 offset)로 + 프리셋 제공 + 그 위에 μ patch 오버레이. 지금은 고객(나)이 규격을 손으로 재현 중인데, 차량동역학 sim이 + 표준 시험을 프리셋으로 주면 (a) 규격 정확성 보장(reviewer 방어), (b) 차량마다 b로 자동 스케일, (c) cone-hit + 판정·통과속도 산출까지 표준화. = control/AV 연구자 공통 수요. **VDSim이 제공하면 첫 고객이 가장 반길 기능.** + +--- + +## 4. 내가 돌린 검증 (VDSim 신뢰도 근거로 공유) + +- **dry 정합**: δ0.04 → r≈0.205 (understeer 정합), 좌선회 시 FzFR>FzFL(하중이전 방향 정확), 정적 전축 Fz≈7050 N + (Ioniq5 제원 정합). +- **patch 거동**: μ patch서 컨트롤러가 grip 초과 demand하면 per-wheel α가 α_peak 넘어가며 drift>1(슬립) = + 요구한 "clamp 아닌 grip loss" 정확히 작동. +- **결정론**: 동일 입력 재현 OK. +- **envelope**: V0 14~32 / μ 0.20~0.50 / patch 22~90 m 전반에서 물리적으로 일관(비물리 발산 없음). deterministic + baseline은 전 영역서 최악, anticipatory는 grip 마진 최고 — 기대한 단조 분리가 plant 위에서 깨끗이 재현됨. + +--- + +## 5. 한 줄 총평 +첫 고객으로서: **`mu_peak`/`alpha_peak` 노출과 load-dependent realized-peak 물리가 이 plant를 control-research용 +GT로 진짜 쓸 만하게 만든다.** 유일한 실질 장벽은 **Python 버전(py3.8 고정) 빌드** — 이거 하나만 매끄럽게 하면 +다음 고객의 첫인상이 크게 좋아진다. drop-in·결정론·한계영역 수치강건성은 그대로 강점. diff --git a/docs/PLANT_DELIVERY_NOTE.md b/docs/PLANT_DELIVERY_NOTE.md new file mode 100644 index 00000000..b6abed23 --- /dev/null +++ b/docs/PLANT_DELIVERY_NOTE.md @@ -0,0 +1,227 @@ +# VDSim Plant — 인수 노트 (thesis-side, BETA #1) + +> 이 문서를 thesis repo(예: `~/vla_design/`) 세션에 그대로 붙여넣어 시작하세요. +> 전체 튜토리얼: `VDSim/docs/VDSIM_PLANT_TUTORIAL.md`, 빠른 참조: `VDSim/python/VDSIM_PLANT_README.md`. + +## 0. 무엇을 받았나 + +기존 Python dynamic-bicycle plant를 **VDSim Ld2 7DOF + Pacejka MF2002 .tir** plant로 교체합니다. +MPC(acados)는 **그대로**, plant 객체만 교체. `step([delta, Fx])` lockstep API. + +- 입력 `u = [delta_roadwheel_rad, Fx_total_N]` (steer + 종방향 force intent, +구동/−제동). +- 출력 obs = ground-truth 차량상태 + per-wheel tyre force (contact frame), 그 step에서 쓴 실제 μ. +- ISO 8855, wheel `FL=0 FR=1 RL=2 RR=3`. RNG 없음 = 완전 deterministic. +- tyre saturation이 진짜다: 과도한 command는 clamp가 아니라 **grip loss(슬립)** 로 나타남. +- spatial μ patch (저마찰 구간) 지원 — "unseen low-μ" 시나리오. + +## 1. 셋업 (1회) + +```bash +cd /home/ailab-12/git/VDSim # branch: VDSim-Thesis +git checkout VDSim-Thesis +cmake -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j # libvdsim_core + python module (build/python/) +``` + +```python +import sys +sys.path += ["/home/ailab-12/git/VDSim/build/python", + "/home/ailab-12/git/VDSim/python"] +from vdsim_plant import VDSimPlant +``` + +### ⚠️ 중요: Python 버전 호환성 + +위의 default build는 **시스템 Python 3.8**로 pybind11 모듈을 빌드합니다. 당신의 MPC 환경 +(예: `conda vla`, py3.11)이 다르면 **import vdsim 실패** (`undefined symbol` 에러). + +**당신의 Python 환경에서 재빌드하세요:** + +```bash +conda activate vla # 당신의 MPC 환경 (py3.11 등) +pip install pybind11 # 그 환경에 설치 +cd /home/ailab-12/git/VDSim +cmake -B build_vla -DCMAKE_BUILD_TYPE=Release -DVDSIM_BUILD_PYTHON=ON \ + -DPython3_EXECUTABLE="$(which python)" \ + -Dpybind11_DIR="$(python -m pybind11 --cmakedir)" +cmake --build build_vla -j +``` + +그 다음 Python code에서: + +```python +import sys +sys.path += ["/home/ailab-12/git/VDSim/build_vla/python", # ← build_vla 사용 + "/home/ailab-12/git/VDSim/python"] +from vdsim_plant import VDSimPlant +``` + +**생략하면 안 됩니다.** MPC env와 plant pybind ABI가 일치해야 import 성공. + +## 2. 교체 레시피 (closed_loop_sim.py) + +기존 bicycle plant의 `plant_deriv`/`rk4`를 아래로 교체. MPC는 손대지 않음. + +```python +plant = VDSimPlant( + config="ioniq5_awd", # configs/vehicles/ioniq5_awd.yaml + base_mu=0.9, + friction_map=[(40.0, 60.0, 0.5)], # 선택: x=40~60m μ=0.5 저마찰 패치 + control_dt=0.1, # = MPC sample (ZOH). substep_dt로 나누어떨어져야 함 + substep_dt=1e-3, # 내부 적분 step (wheel-spin stiff → ≤1ms) +) +obs = plant.reset(state0=[X0, Y0, psi0, vx0, vy0, r0]) + +for k in range(N): + x = [obs["X"], obs["Y"], obs["psi"], obs["vx"], obs["vy"], obs["r"]] + delta, Fx = mpc.solve(x) # 당신의 acados MPC — UNCHANGED + obs = plant.step([delta, Fx]) +``` + +obs → MPC state 매핑 (동일 convention이라 그대로 사용): + +| obs | 의미 | MPC state | +|---|---|---| +| `X, Y, psi` | world pose | x, y, ψ | +| `vx, vy, r` | body vel + yaw rate | v_x, v_y, r | +| `ax, ay, beta` | 가속도(specific force), sideslip | (추정/로깅용) | +| `wheel[i].{Fx,Fy,Fz,alpha,kappa,mu}` | per-wheel GT (contact frame) | friction-circle 분석용 | + +friction-circle 판정은 분석자 몫: `‖[Fx,Fy]‖ / (mu·Fz)` per wheel. plant는 usage metric을 내보내지 않음(thesis-side 전용). + +## 3. 이번 베타에서 바뀐 것 — tyre를 MF2002로 (당신 서사와 직결) + +이전 MF96은 Pacejka 계수가 하중 무관(상수 B/C/E, Ca∝Fz 선형)이었음. → **MF2002 .tir**로 교체: + +| 효과 | 수치 (검증됨) | +|---|---| +| cornering stiffness 하중 포화 (concave) | `Kya(2Fz0)/Kya(Fz0)=1.435 (<2)` | +| peak μ 하중 민감도 | `0.90 → 0.81 @ 2·Fz0` (PDY2=−0.10) | +| 단일 .tir로 front/rear Ca 자동 분리 | `Kya(7011)=109.3k→Caf≈218k`, `Kya(4558)=79.5k→Car≈159k` (목표 220/160, ~1%) | +| friction patch | contact μ=0.5 → peak 0.50 (정상 스케일) | + +의미: 하중이전(load transfer) → 바깥 휠 Fz↑ → Ca 포화 + μ 저하 → 조기 한계/understeer 가 +plant에 들어옴. 즉 **Fz 변동이 grip에 비선형으로 작용**. σ_Fz를 covariance로 넘겨 +chance-constraint로 쓰는 구조의 plant-side 물리근거가 생김. MPC 내부모델(linear-Ca bicycle)과 +plant(load-dependent)의 mismatch는 의도된 robustness stress — matched plant보다 강한 서사. + +## 4. 검증 상태 + +- `cd build && ctest -R VlaPlant --output-on-failure` → 10/10. 전체 394/394 green. +- dry handling = linear bicycle 내 ~4% (analytic-B tyre + 실제 MF2002 .tir 양쪽 확인). +- friction circle `‖[Fx,Fy]‖ ≤ μ·Fz` 매 step, 제동이 휠을 역회전시키지 않음. +- 5s 궤적 ≈ 25ms wall-clock (≫ real-time → sweep 저렴). + +## 5. 베타 피드백 요청 (이게 핵심) + +당신은 첫 베타테스터입니다. 못박힌 게 아니라 **써보고 장단점 회신 → 다음 iterate** 단계. +다음을 중심으로 회신 바람: + +1. API 마찰 — `step/reset` 시그니처, obs 키/단위/frame이 MPC 루프에 그대로 맞물리는가? +2. tyre 거동 — dry yaw-rate gain이 기존 bicycle 결과와 충분히 일치하나? 한계영역(패치 진입) + 거동이 thesis 시나리오에 쓸만한가? μ/Fz 수치가 기대범위인가? +3. 누락 채널 — MPC/estimator가 필요한데 obs에 없는 양(예: 개별 휠 Fz GT 외 추가 신호)? +4. 성능 — 당신 sweep 규모에서 속도/결정론이 충분한가? +5. 파라미터 — ioniq5_awd 제원이 thesis 차량 가정과 어긋나는 부분? + +회신 형식 자유(슬랙/이슈/메모). 회신 받으면 VDSim-Thesis 브랜치에서 개선 → 안정화 후 main 병합. + +## 6. 제약 (참고) + +- 현재 VDSim-Thesis 브랜치. main은 보호 중(아직 미병합). +- tyre 계수는 전부 public-synthetic Ioniq5급 근사 — 실측/대외비 데이터 아님. + +--- + +## BETA #1 결과 로그 (2026-06-18, thesis-side rhdusal22) + +결론: **통합 성공.** drop-in 잘 맞물림, 첫 closed-loop 결과가 bicycle plant보다 깨끗. +핵심 evidence — slip-angle 단조분리: det(σ=0) 8.3° (슬립/이탈, useGT 1.08) vs frozen 3.2° vs +antic 2° (planted, useGT 0.91). load-dependent MF2002가 anticipatory vs deterministic 분리를 +bicycle(soft clamp)보다 선명하게 만듦. + +피드백 5항목: API 마찰 거의 없음 / tyre 거동 기대대로(load transfer 확인: FzFR 8631>FzFL 5503 +좌선회) / 누락 채널 없음 / 성능·결정론 충분 / 파라미터 정합(Caf 218k·Car 159k ~1%). 부호 +δ→+r→+Y ✓. + +조치 (main 병합 전 정리): +- [x] **item 0 — 외부 인터프리터 재빌드 문서**: shipped `.so`가 cpython-38, thesis MPC는 conda + py3.11 → ABI 불일치 import 불가. 튜토리얼 §1에 conda/venv 재빌드 절 + `VDSIM_BUILD_PYTHON` + default OFF 명시 추가 완료. +- [x] **useGT max=1.08>1 정의 확정** (아래 §7). friction-circle 위반 아님 — 보고 μ 정의 문제. + option A 채택: obs에 `mu_peak` 추가. thesis-side 독립 재현 일치(det 1.08→1.00, plant probe + 1.046→0.998). 양쪽 sync 완료. + +useDem proxy 오해유발 건은 thesis-side 이슈(plant 책임 아님): VDSim에선 plant GT(slip angle· +useGT)를 headline metric으로 쓰는 게 맞음. + +--- + +## 7. useGT > 1 해명 (검증 완료) + fix 옵션 + +증상: `useGT = ‖[Fx,Fy]‖ / (wheel.mu · Fz)` 가 det 모드 최대 1.08 (>1). + +원인 (코드 확인): **friction-circle 위반이 아니다.** `wheel.mu`는 `wheel_mu()` = +`min(contact.mu_long, contact.mu_lat)` = **노면(contact) μ** (base 0.9 또는 patch), MF2002가 +실제로 쓴 load-dependent peak 계수가 아님. MF2002 lateral peak (`magic_formula_tire.cpp:65,87`): + +``` +muy(Fz) = (PDY1 + PDY2*dfz) * (LMUY * mu_contact), dfz = (Fz - Fz0)/Fz0 +Dy = muy * Fz # 실제 한계 force +``` + +PDY2 = -0.10 < 0 → 저하중(dfz<0)에서 `muy > mu_contact`. 가벼운 휠(예: combined brake+turn 시 +inner-front, dfz≈-0.8 → Fz≈0.2·Fz0)에서 `muy ≈ 1.08·mu_contact`. 그래서 ‖F‖가 +1.08·(mu_contact·Fz)까지 도달 → useGT=1.08. **타이어는 자기 자신의 load-dependent ellipse +안에 있음**; metric이 분모에 nominal μ를 써서 생긴 정의 불일치. (이건 peak μ가 하중↓에서 nominal +위로 올라가는 MF2002의 의도된 거동 — MF96엔 없던 것.) + +**결정: (A) 채택** (2026-06-18). obs에 **realized peak 계수** 노출 — `wheel.mu_peak` = +`max(mu_x, mu_y)` (load-dependent, force/Fz). `useGT = ‖F‖/(mu_peak·Fz) ≤ 1` 정의상 보장. +`wheel.mu`(노면 μ)는 보존. ITireModel::Output/Wrench + `wheel_mu_peak()` + obs schema 확장. +이 .tir는 PDX==PDY라 mu_x==mu_y → mu_peak가 한계영역에서 정확히 useGT==1. +검증: combined brake+turn on patch 시나리오에서 모든 wheel·step `‖F‖ ≤ mu_peak·Fz`. + +--- + +## Changelog (BETA iterate 기록) + +semantics 미묘 차(`mu`=contact vs `mu_peak`=realized)는 라운드 간 추적 필요 → durable 기록. + +### BETA #2 — 2026-06-18 (peak-slip 노출, sat 대체) +- **[obs additive]** `wheel[i].alpha_peak`(lateral peak slip angle [rad]) + + `wheel[i].kappa_peak`(longitudinal peak slip ratio [-]) 추가 = 현재 Fz·μ에서 force가 peak에 + 도달하는 slip 위치(load+μ 반영, MF 곡선에서 Newton 해). linear는 peak 없음→`inf`. + - **설계 전환**: `sat`(파생 ratio)을 권하는 대신 **operating-point raw 특성**을 줌 — 용량 + (`mu_peak`) + peak 위치(`alpha_peak`/`kappa_peak`) + 현재 위치(`alpha`/`kappa`). 분석자가 + `|alpha| vs alpha_peak`로 rising/peak/sliding(드리프트)을 **모호함 없이** 판정 → `sat` + 단독 비단조 문제 해소. plant는 metric 미탑재(raw GT only) 원칙 강화. + - 코어: `mf_peak_slip(B,C,E)` (interfaces.hpp, 공용), Output/Wrench + `wheel_alpha_peak()`/ + `wheel_kappa_peak()`, backend별 채움, evaluate() 전파. + - test: `VlaPlant.PeakSlipMatchesNumericalArgmax` (model vs Fy/Fx argmax, Fz 3종, 5%). 396 green. + - 검증(plant probe): gentle turn |α|0.011<α_peak0.140=reserve; brake+turn μ0.4 + |α|0.162>α_peak0.065=drift. dry α_peak≈8.0°가 det slip 8.3°(=peak)와 일치. + +### BETA #2 — 2026-06-18 (피드백 #1 반영) +- **[obs additive, NOT breaking]** `wheel[i].mu_peak` 추가 = realized load-dependent peak 계수 + (force/Fz). friction-circle GT는 이걸 분모로: `useGT=‖[Fx,Fy]‖/(mu_peak·Fz) ≤ 1`. 기존 + `wheel[i].mu`(노면 contact μ)는 보존 — 의미 분리. (useGT>1 정의 문제 해소; §7.) + - 코어: `ITireModel::Output/Wrench::mu_peak`, `IVehicleDynamics::wheel_mu_peak()` (Ld2), + backend별 compute() 채움(MF2002/MF96/linear), `evaluate()` 전파. + - test: `VlaPlant.RealizedPeakMuBoundsFrictionCircle`. 395/395 green. + - thesis-side 독립 재현 일치 (det useGT 1.08→1.00). +- **[docs]** 튜토리얼 §1: 외부 인터프리터(conda/venv) 재빌드 절 + `VDSIM_BUILD_PYTHON` default + OFF 명시 (item 0). +- thesis-side 확정: mu_peak-based friction-utilization이 P1 gate에 정합. main 병합 blocker 없음. +- **[naming]** `useGT` → **`sat` (friction saturation ratio)** 로 명명 변경. 사유: (1) "GT"는 + thesis-side의 GT/Dem 출처 구분 꼬리표라 VDSim 일반 고객엔 무의미(Dem 짝 없음); (2) "use" + (utilization)는 오해유발 — force-slip 곡선이 peak 후 falling tail이라 `sat`은 **단조 사용율이 + 아님**(peak slip에서만 1, rising/sliding 양쪽 다 <1). 한계 판정은 `sat` 단독 X, **slip 병기** + 필요. VDSim 문서(튜토리얼 §4·README)는 `sat`로 갱신. **thesis-side 코드 변수 `useGT/useDem` + → `satGT/satDem` rename은 그쪽 repo 작업**(VDSim repo엔 metric 변수 없음 — raw GT only). +- **[declined]** 고객 저우선 재요청 `wheel.grip_usage=‖F‖/(mu_peak·Fz)` obs 직접 노출 → + **미채택**. 사유: "plant=raw GT only, usage metric은 thesis-side 소유" 설계 원칙 유지 + (튜토리얼 §4 명시). grip_usage는 obs로 1줄 파생 → 분석자 계산. (재논의 방지용 기록.) + +### BETA #1 — 2026-06-18 (초기 인도) +- Ld2 7DOF plant + MF2002 `.tir` (load-dependent Ca/peak μ), `VDSimPlant.step([delta,Fx])` + lockstep, friction patch, per-wheel GT obs. 통합 성공 (§BETA #1 결과 로그). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 589d8605..02793d56 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # VDSim product roadmap -**Last updated:** 2026-06-20 · **Tests:** 388/388 ctest green (`main`) · 402 on `VDSim-Thesis` (+ VLA plant) +**Last updated:** 2026-09-04 · **Tests:** [canonical validation status](VALIDATION.md) Living roadmap from early PoC through v0.6. Tracks what shipped in **`main`** vs what is planned. The **VLA thesis closed-loop plant** (`python/vdsim_plant.py`) ships on @@ -28,8 +28,8 @@ tire + direct-control session factory** on `main` → **VLA plant beta** on thes | Brake / steer | Pluggable modules + deadtime; **user-defined modules (C++/Python subclass)** | Booster/MDPS physics | | Catalog / runtime | `--scene=`, fleet, FMI; **`make_direct_control_session`**, `FrictionMapConfig`, `SimOutput` wheel GT | External part packs; VDS1 v4; **Fx→τ contract (#1)** | | Closed-loop plant | Core session API on `main` | **VLA `VDSimPlant`** thesis-only → main PR when client done | -| GUI | 3-tab scene UI, catalog API, workshops, multi-vehicle compare | **GUI v2** (framework decision); terrain Play; `.tir` import | -| Validation | ISO 7401/4138/3888 **re-baselined + CI-gated** (`IsoBaseline`), **388** ctest | Adams x-check | +| GUI | 3-tab scene UI, catalog API, workshops, multi-vehicle compare | **GUI v2** (framework decision); terrain Play; `.tir` import; **expose user-defined modules (decide: C++ `.so` plugin / Python path / GUI authoring)** | +| Validation | ISO 7401/4138/3888 **re-baselined + CI-gated** (`IsoBaseline`); [canonical suite status](VALIDATION.md) | Adams x-check rtol; commercial cross-val | ```mermaid timeline @@ -122,7 +122,7 @@ timeline | [x] CLI `--lugre` / `--no-lugre` | Shipped | | | [x] GUI LuGre toggle + `fleet_overrides` persist | Shipped | | | [x] Scene `lugre_grade_demo.yaml` | Shipped | | -| [x] `LuGreTire/*` integration tests | Shipped | 209 ctest suite | +| [x] `LuGreTire/*` integration tests | Shipped | Included in the [canonical suite](VALIDATION.md) | | [x] Theory ch.19 + user docs synced | Shipped | | | [x] **Per-axle `TireSetup`** (front/rear yaml, L1/L2/L3/L5) | Shipped 2026-06-20 | `PerAxleTire.*`; catalog `tire_rear` slot | | [x] **Per-wheel `TireSetup`** (FL/FR/RL/RR corner yaml) | Shipped 2026-06-20 | `PerWheelTire.*`; catalog `tire_fr`/`tire_rl`/`tire_rr` | @@ -133,6 +133,7 @@ timeline |-------|------|--------| | T1 | `.tir` / MF2002 as **catalog** tire backend (`TireParams.backend`) | [x] merged | | T1 | MF2002 combined slip bypasses host friction ellipse | [x] merged | +| T1 | **Tire model selectable per-part** via `backend: mf96 \| mf2002 \| linear` — all three live in `create_tire_from_params` (mf96 default, mf2002→.tir, linear→`create_linear_tire`), routed by every dynamics (L1/L2/L5). | [x] infra present; TODO: formalize `linear` (catalog preset + tests + docs), confirm L1/L5 parity. Requested 2026-06-18 | | T1 | GUI tire import + public sample `.tir` | [ ] (v0.5.2 GUI) | | T1 | Chrono Pac02 parity gate (`ctest -R ChronoPac02Parity`) | [x] pure-long ~2% + pure-lat ~1% gated | | T1 | LuGre $g()$ from MF2002 $F_{x0},F_{y0}$ (optional) | [ ] | @@ -242,6 +243,12 @@ LuGre (contact bristle, presliding). See [`TIRE_ROADMAP.md`](design/TIRE_ROADMAP | [x] **`SimOutput` wheel GT** (μ, μ_peak, α/κ peak, `tire_forces_wheel`) | Shipped 2026-06-20 | `SimSession::output()` | | [x] **`ladder_lowering.hpp`** (ISO-frozen L1–L3→L4 calib) | Shipped 2026-06-20 | was duplicated in dynamics | | [x] Steer deadtime via **ActuatorModel** (not ad-hoc lag) | Shipped 2026-06-20 | `vp.steer_deadtime_s` on direct path | +| [x] Plant data-access view `plant.vehicle.tire.{model,params}` (vehicle→part→physics, read-only) | Shipped 2026-06-18 | tyre only | +| [ ] Extend the data-access view to brake/steering/drivetrain/suspension/ARB parts | TODO | needs const module getters on IVehicleDynamics (only set_*_module exists); tyre is the only part with a live model handle today | +| [ ] **VLA Thesis Plant API P1:** roll angle in obs (`obs["roll"]`, `obs["pitch"]`) | Planned | BETA #1 customer request; already computed in L2–L5 | +| [ ] **VLA Thesis Plant API P1:** split-μ friction map (left/right different grip) | Planned | `create_split_mu_ground` exists in C++; needs Python API surface | +| [ ] **VLA Thesis Plant API P2:** steering actuator lag option (selectable 1st-order LPF) | Planned | `VehicleParams.steer_deadtime_s` implemented; needs API convenience + docs | +| [ ] **VLA Thesis Plant API P2:** smooth friction patch transition (tanh vs step μ) | Planned | Reduces numerical jitter at patch boundaries | | [x] `tools/import_part_pack.py` stub | Shipped | M5 | | [x] **VLA plant beta** (`VDSimPlant`, friction patch, obs contract) | **Thesis-only** | tag `vdsim-thesis-beta`; [`design/VLA_THESIS_PLANT.md`](design/VLA_THESIS_PLANT.md) | | [x] VLA P1: `obs["roll"]`, `obs["pitch"]` | **Thesis-only** | L2–L5 qs angles | @@ -298,10 +305,7 @@ LuGre (contact bristle, presliding). See [`TIRE_ROADMAP.md`](design/TIRE_ROADMAP | [x] ISO 4138 understeer gradient | Shipped | | | [x] ISO 3888-2 DLC metric | Shipped | | | [x] ISO 8608 road PSD classes | Shipped | | -| [x] **382** automated ctests | Shipped | 2026-06-17 | -| [x] **385** automated ctests (`main`, post core port) | Shipped | 2026-06-20 | -| [x] **388** automated ctests (`main`, SessionKind + KC + T3) | Shipped | 2026-06-20 | -| [x] **402** on `VDSim-Thesis` (+ VLA plant suite) | Shipped thesis | 2026-06-20 | +| [x] Automated ctest suite; [current result](VALIDATION.md) | Shipped | 2026-06-17 | | [x] ISO matrix re-baseline (post engine inertia + LuGre) + CI gate | Shipped | `IsoBaseline`; VALIDATION.md table 2026-06-10 | | [x] Stunt / L5 validation suite | Shipped | `test_stunt`, `test_l5_strut_validation` (loop critical, ballistic jump, camber=L4 DAE, gyro, energy) | | [x] **KC geometry cross-validation** (L5 DAE travel ≡ independent native kinematics, rtol-gated) | Shipped | `MultibodyKcXval.DaeTravelMatchesNativeKinematics`; rules out engine bugs (internal consistency) | @@ -384,7 +388,8 @@ Single tag **v0.4.0** per [`V0.4_PLAN.md`](design/V0.4_PLAN.md). Does not block - [x] Gyroscopic wheel-spin coupling (yaw↔roll at speed) - [x] Per-substep contact re-query default in `SimSession`; contact sampled at the wheel particle (exact on curved surfaces, no false contact leaving a loop/bank) -- [x] 385/385 ctest; design [`L5_MBD_FREE3D_UNSPRUNG.md`](design/L5_MBD_FREE3D_UNSPRUNG.md) +- [x] Canonical suite status tracked in [Validation](VALIDATION.md); design + [`L5_MBD_FREE3D_UNSPRUNG.md`](design/L5_MBD_FREE3D_UNSPRUNG.md) ### v0.6.1 — core port from thesis (2026-06-20, on `main`) @@ -392,14 +397,12 @@ Single tag **v0.4.0** per [`V0.4_PLAN.md`](design/V0.4_PLAN.md). Does not block - [x] `make_direct_control_session`, `FrictionMapConfig`, polygon friction canonical path - [x] `IVehicleDynamics::tire(wheel)`, `SimOutput` wheel GT fields - [x] `ladder_lowering.hpp`; steer deadtime via actuator on direct-control path -- [ ] **v0.6.2 tag** — SessionKind + KC compliance + T3 VLOW (code ready) ### v0.6.2 — core quality + tire T3 (2026-06-20, on `main`) - [x] `SessionKind` (`Standard` / `DirectControl`) replaces `SimConfig.direct_control_path` - [x] KC bushing compliance in L3/L4 hard-joint step (`compliance_targets_rad` under tire load) - [x] T3: `TireParams.low_speed_mode` (`kinematic_blend` | `tire_vlow`) + `vlow_speed_eps` -- [x] 388/388 ctest ### v0.5.2+ (planned) @@ -418,11 +421,11 @@ Single tag **v0.4.0** per [`V0.4_PLAN.md`](design/V0.4_PLAN.md). Does not block **Shipped today** > Open-core L1–L5 vehicle dynamics with Pacejka MF tire, optional LuGre brush + belt -> transient layers, opt-in MF2002 `.tir` backend, per-wheel tire setup, drivetrain v2 -> (engine map + gearbox), parts catalog, real-time UDP/FMI, and ISO-standard validation -> (385 ctests on `main`). The L5 model is a hardpoint-driven free-3D multibody: -> anti-dive/squat, roll-centre migration and a progressive coil rate emerge from the -> suspension geometry, energy-consistent on arbitrary surfaces (banks, loops, jumps). +> transient layers, opt-in MF2002 `.tir` backend, per-wheel tire setup and drivetrain v2 +> (engine map + gearbox), parts catalog, real-time UDP/FMI, and +> [ISO-standard validation](VALIDATION.md). The L5 model is a hardpoint-driven free-3D +> multibody: anti-dive/squat, roll-centre migration and a progressive coil rate emerge from +> the suspension geometry, energy-consistent on arbitrary surfaces (banks, loops, jumps). **In active development** @@ -441,6 +444,10 @@ Single tag **v0.4.0** per [`V0.4_PLAN.md`](design/V0.4_PLAN.md). Does not block | Doc | Role | |-----|------| +| **[`PLANT_CUSTOMER_FEEDBACK.md`](PLANT_CUSTOMER_FEEDBACK.md)** | **BETA #1 customer feedback (VLA thesis); P1/P2 feature requests** | +| [`VDSIM_PLANT_TUTORIAL.md`](VDSIM_PLANT_TUTORIAL.md) | External closed-loop plant user guide | +| [`PLANT_DELIVERY_NOTE.md`](PLANT_DELIVERY_NOTE.md) | Thesis-side handoff (setup, API, validation) | +| [`design/VLA_THESIS_PLANT.md`](design/VLA_THESIS_PLANT.md) | Plant specification | | [`design/V0.2_PLAN.md`](design/V0.2_PLAN.md) | v0.2 workstreams (historical + status) | | [`design/V0.4_PLAN.md`](design/V0.4_PLAN.md) | Stunt / Ld5 | | [`design/V0.4_SLOPE_JUMP_DYNAMICS.md`](design/V0.4_SLOPE_JUMP_DYNAMICS.md) | Grade, terrain, T23 jump path | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index faa65912..056ca6f4 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -1,6 +1,6 @@ # VDSim validation & credibility -> **v0.5.1+ experimental / pre-release.** The claims below rest on open evidence +> **v0.6.2+ experimental / pre-release.** The claims below rest on open evidence > (analytic + ISO standard + cross-model/cross-tool self-consistency). There is > no published cross-validation against a commercial reference on real-vehicle > data — see "Honest limitations". Not for production use. Do **not** claim @@ -20,13 +20,46 @@ the last section. | ISO 7401 step-steer / 4138 understeer / 3888-2 DLC — run + measured | — | | L1↔L2↔L3 cross-model consistency where physics overlaps | Dependent axles (twist-beam / solid beam) — configs are stubs | | FMI round-trip Δ=0 (machine precision); ISO 8608 PSD RMS per class | L3 unsprung lateral-transfer term (small) | -| Full suite: **391/391 ctest green** (measured 2026-06-25, `main`) | — | +| Full suite: **485/485 ctest green** (`validation` preset, as of 2026-09-06) | — | | Drivetrain engine inertia (open-diff carrier coupling) | — | | **ISO step-steer signature gated in CI** (`ctest -R IsoBaseline`, sedan L2 LuGre) | DLC moose gate is a preset property, not a defect (see note) | Note: ISO 3888-2 DLC@60 not meeting the 1.0 m gate is a default-preset vehicle/controller property, not a sim defect (see "Notes on specific results"). +## Test-suite currency + +The suite count below is the only number in this document that is machine-checked +(`scripts/check_validation_currency.py`, run as the `validation_currency` ctest). +It records the **canonical configuration only** -- the `validation` CMake preset. +Counts measured in any other configuration are deliberately not recorded here, so +that one number cannot drift against another. + +`commit:` is the sha of the tree that was *measured*; this document is a +descendant of it, so the gate accepts the recorded commit when it is that +sha or an ancestor of it. Ancestry alone is not enough: the registration +surface (`CMakePresets.json`, every `CMakeLists.txt`, `cmake/**`) has to +be unchanged between that sha and the measured tree, because those files +decide which tests exist. Test sources are outside that surface -- their +effect is caught by the passed/total comparison instead. `toolchain:` pins +the CMake that performed the +measurement -- a toolchain bump is a re-measurement trigger, so the new +version and the new numbers have to land in the same commit. + + +```text +tests: 485/485 +config: cmake --preset validation && cmake --build --preset validation && ctest --preset validation +presets: CMakePresets.json@fb1f0c198a0201fcce2543a9f5b2242a255fb97a +toolchain: cmake 3.31.10 +commit: f9cf765 +date: 2026-09-06 +excluded: gui_v3_e2e (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) +excluded: gui_v3_api_smoke (GUI v3 test group formally deferred -- VDSIM_BUILD_GUI_V3_TESTS=OFF) +excluded: ergaccess (optional external dependency not installed -- VDSIM_WITH_ERGACCESS=OFF) +``` + + ## Validation layers 1. **Analytic** — closed-form results the model must reproduce (linear bicycle @@ -78,7 +111,7 @@ matching MF-Tyre/CarMaker. Initial wheel spin is set per-wheel via `free_roll_wh (vx/Re at static load) so no phantom longitudinal force appears at t=0. Re is opt-in: tires without BREFF/DREFF/FREFF (reff_*=0) fall back to the unloaded radius R0 unchanged. -Full automated suite: `cd build && ctest` — **391** checks, 100% green (measured 2026-06-25, `main`; thesis GUI adds 13 more → 404). +Full automated suite: `ctest --preset validation` — **485** checks, 100% green (see the currency block above). One-command evidence bundle (ctests + tire parity table + ISO figures + summary.md): `python3 tools/validation_report.py`. @@ -167,7 +200,7 @@ folded into the re-baselined table above. ## Reproducing the whole report ```sh -cmake --build build -j && (cd build && ctest --output-on-failure) # 391 checks on main +cmake --build build -j && (cd build && ctest --output-on-failure) # 485 checks python3 apps/validation/run_validation.py # ISO 7401/4138/3888 -> REPORT.md python3 fmi_export/test_roundtrip.py # FMU vs native ``` diff --git a/docs/VDSIM_PLANT_TUTORIAL.md b/docs/VDSIM_PLANT_TUTORIAL.md new file mode 100644 index 00000000..8bbcf4c2 --- /dev/null +++ b/docs/VDSIM_PLANT_TUTORIAL.md @@ -0,0 +1,496 @@ +# VDSim Plant — tutorial (closed-loop control plant) + +A step-by-step guide to using **VDSim as the vehicle-dynamics plant** for a closed-loop +controller (e.g. an MPC). You call `step()`; VDSim integrates one control period and returns +ground-truth state + per-wheel tyre forces. Mid-fidelity (7-DOF + Pacejka MF2002 `.tir` with +load-dependent coefficients + load transfer), deterministic, sub-real-time — a drop-in upgrade +from a single-track bicycle plant. + +> Status: **BETA** (first release). The API below is stable for use; expect refinements from +> early feedback. Branch `VDSim-Thesis`. + +--- + +## 0. What you get + +- Control input each step: `u = [delta_roadwheel_rad, Fx_total_N]` — the road-wheel steer angle + and a longitudinal **force intent** (no pedal/throttle calibration; +Fx drives, −Fx brakes). +- Observation each step: true vehicle state + per-wheel tyre forces in the **contact frame**, + with the actual friction each wheel used. Everything ISO 8855, wheels `FL=0,FR=1,RL=2,RR=3`. +- Real tyre saturation: a too-aggressive command **loses grip** (the tyre slips), it is not + silently clamped — this is the point of using VDSim over a toy plant. +- Spatially varying friction (low-μ patches) for "unseen low-μ" scenarios. + +--- + +## 1. Build (one time) + +```bash +cd /path/to/VDSim +cmake -B build -DCMAKE_BUILD_TYPE=Release -DVDSIM_BUILD_PYTHON=ON # first time +cmake --build build -j # core + python module +``` + +`VDSIM_BUILD_PYTHON` defaults **OFF** — you must pass `-DVDSIM_BUILD_PYTHON=ON` to get the +`vdsim` pybind module under `build/python/`. + +### ⚠️ Critical: Building for your Python environment (conda / venv) + +The pybind module is **ABI-locked to one Python interpreter**. A shipped `vdsim.cpython-38-*.so` +will **not import** under a different interpreter (e.g. a conda env on 3.11 — the typical +acados / cvxpy MPC setup). + +**If your controller runs in its own environment (recommended), you MUST rebuild the module +with that interpreter:** + +```bash +conda activate vla # your MPC env (e.g. python 3.11) +pip install pybind11 # into that env +cmake -B build_vla -DCMAKE_BUILD_TYPE=Release -DVDSIM_BUILD_PYTHON=ON \ + -DPython3_EXECUTABLE="$(which python)" \ + -Dpybind11_DIR="$(python -m pybind11 --cmakedir)" +cmake --build build_vla -j +``` + +Then point `sys.path` at `build_vla/python` (not `build/python`). + +**Symptom of a mismatch:** `ImportError: ... undefined symbol` or `module compiled against a +different Python`. See the command above — do not skip the `-DPython3_EXECUTABLE` / `-Dpybind11_DIR` +flags. + +--- + +## 2. Import the plant + +`VDSimPlant` lives in `python/vdsim_plant.py`; the compiled `vdsim` module in `build/python/`. +Point Python at both: + +```python +import sys +sys.path += ["/path/to/VDSim/build/python", "/path/to/VDSim/python"] +from vdsim_plant import VDSimPlant +``` + +--- + +## 3. Quickstart (copy-paste, runs in seconds) + +```python +import sys +sys.path += ["/path/to/VDSim/build/python", "/path/to/VDSim/python"] +from vdsim_plant import VDSimPlant + +plant = VDSimPlant( + config="ioniq5_awd", # bundled preset (configs/vehicles/ioniq5_awd.yaml) + base_mu=0.9, # uniform dry friction + control_dt=0.05, # one step() = 0.05 s of sim, held ZOH + substep_dt=5e-4, # internal fixed integration step (stiff wheel spin) +) + +obs = plant.reset(state0=[0.0, 0.0, 0.0, 16.7, 0.0, 0.0]) # [X,Y,psi,vx,vy,r]; vx=16.7 m/s + +for k in range(100): # 5 s + delta = 0.03 # rad, road-wheel + Fx = -1500.0 # N, light brake (force intent) + obs = plant.step([delta, Fx]) + print(f"t={k*plant.control_dt:4.2f} yaw_rate={obs['r']:+.3f} ay={obs['ay']:+.2f}" + f" FL_kappa={obs['wheel'][0]['kappa']:+.3f}") +``` + +A ready-to-run version is `examples/vla_plant_demo.py`: + +```bash +cd /path/to/VDSim && python3 examples/vla_plant_demo.py # writes /tmp/vla_plant_demo.csv +``` + +--- + +## 4. The observation dict + +`reset()` and `step()` both return one `obs` dict (true state — no sensor noise): + +| key | meaning | units / frame | +|-----|---------|---------------| +| `X`, `Y` | position (world) | m, ISO 8855 ENU | +| `psi` | yaw | rad | +| `vx`, `vy` | body-frame velocity | m/s (+vx fwd, +vy left) | +| `r` | yaw rate | rad/s (+ = left turn) | +| `ax`, `ay` | body specific force (accelerometer) | m/s² | +| `beta` | side-slip `atan2(vy,vx)` | rad | +| `roll`, `pitch` | quasi-static roll / pitch (L2–L5; L1 → 0) | rad | +| `wheel[i]` | per wheel `i` (FL0,FR1,RL2,RR3) | dict below | +| `wheel[i].Fx`, `.Fy` | tyre force, **contact/wheel frame** | N (+Fx drive, +Fy left) | +| `wheel[i].Fz` | vertical load | N | +| `wheel[i].alpha` | slip angle | rad | +| `wheel[i].kappa` | slip ratio | – | +| `wheel[i].mu` | **road-surface** friction at this wheel (contact μ) | – | +| `wheel[i].mu_peak` | **realized** tyre peak coefficient at this Fz (load-dependent) | – | +| `wheel[i].alpha_peak` | slip **angle at the lateral force peak**, this Fz/μ (`inf`=no peak) | rad | +| `wheel[i].kappa_peak` | slip **ratio at the longitudinal force peak**, this Fz/μ (`inf`=no peak) | – | + +The plant gives you the tyre's local **operating-point characteristics** as raw ground truth +and leaves any metric to you. Per wheel you get: + +- **`mu_peak`** — realized tyre peak coefficient at this Fz/μ (load-dependent). Rises above + contact μ at low load (`MF2002 PDY2 load sensitivity`). +- **`alpha_peak`, `kappa_peak`** — slip position of the pure-axis force peak. This is the + **recommended core safety metric for limit-handling evaluation**: + +### Recommended safety metric: slip-position classification + +Compare **current slip to peak slip** per wheel. The force-slip curve peaks then falls, making +a single "grip-usage" scalar ill-defined past the peak. Instead, expose the raw positions and +classify directly: + +| `|alpha|` vs `alpha_peak` | interpretation | +|---|---| +| `< alpha_peak` | **rising side** — grip in reserve (understeer control room) | +| `≈ alpha_peak` | **at peak** — grip limit reached | +| `> alpha_peak` | **past peak** — drift / departure (sliding tail, no control recovery) | + +**Same logic for `|kappa|` vs `kappa_peak` longitudinally.** Why this works: +- **Unambiguous:** Slip position directly tells you which side of the peak you are on (future grip + available vs exhausted). +- **Load-dependent:** Both `alpha_peak` and `mu_peak` account for vertical load transfer and + MF2002 load sensitivity. +- **Control-relevant:** Control laws can use `reserve = (alpha_peak - |alpha|) / alpha_peak` to + detect margin loss before a slip event. + +(For a friction-saturation ratio if you want one: `sat = ‖[Fx,Fy]‖ / (mu_peak·Fz)`, bounded by +1. Use `mu_peak`, **not** `mu`: the realized peak rises above nominal at low load, so +`‖F‖/(mu·Fz)` can read >1 while the tyre is inside its own ellipse.) + +### Offline tyre query — combined-slip peak / friction ellipse + +`alpha_peak`/`kappa_peak` in the obs are the **pure-axis** peaks (lateral peak at κ=0, long +peak at α=0). Under **combined** slip the true peak shifts (lower slip, and the resultant grows +toward the ellipse) — there is no single combined peak slip; it depends on the slip direction. +Rather than bake that in, the plant exposes the **tyre evaluator itself**, so you reconstruct +the exact combined force surface and find the peak in any direction yourself (same model the +plant runs — no coefficient dump, nothing to re-implement): + +Data access mirrors **vehicle → part → physics** (a read-only view for data delivery; the +runtime itself stays flat): + +```python +plant.vehicle.params # VehicleParams (mass/geometry/drivetrain/steering/brake/aero) +plant.vehicle.tire.params # TireParams (backend, coefficients, lugre/belt) +plant.vehicle.tire.model # live ITireModel (== plant.tire_model shortcut) +``` + +```python +import vdsim, math +# Preferred: the LIVE tyre the plant runs (single source of truth, no .tir re-load): +tire = plant.vehicle.tire.model # or the shortcut plant.tire_model +# Or load a standalone instance from a .tir: +# tire = vdsim.create_magic_formula_tire_from_tir("configs/parts/tire/ioniq5_pac2002.tir") + +def F(kappa, alpha, Fz=5764.0, mu=0.9): + i = vdsim.TireInput(); i.Fz=Fz; i.kappa=kappa; i.alpha=alpha; i.mu_long=mu; i.mu_lat=mu + o = tire.compute(i) # also exposes o.mu_peak / o.alpha_peak / o.kappa_peak + return o.Fx, o.Fy + +# combined peak along a fixed slip direction (kappa:alpha ratio): 1-D search +def combined_peak(ratio, Fz=5764.0, mu=0.9): + best, bestm = (0.0, 0.0), 0.0 + s = 0.0 + while s <= 0.5: + fx, fy = F(ratio*s, s, Fz, mu); m = math.hypot(fx, fy) + if m > bestm: bestm, best = m, (ratio*s, s) + s += 0.001 + return best, bestm # ((kappa*, alpha*), |F|*) +``` + +E.g. for this `.tir` at Fz=5764, μ=0.9: pure-lat peak α≈0.130; at κ:α=1 the combined peak is at +α≈0.079 with a larger resultant — the load-dependent shift the pure `alpha_peak` cannot show. + +--- + +## 5. Control input `u = [delta, Fx_total]` + +- `delta` — road-wheel steer angle [rad] (what an MPC outputs; no hand-wheel ratio). +- `Fx_total` — total longitudinal **force intent** [N], +drive / −brake. The plant converts it + to per-wheel drive/brake torque (50/50 axle split, `drive_split_front`) and applies it to the + wheel-spin dynamics, so the tyre's combined-slip limit is real: + - moderate command → realised `ΣFx ≈ Fx_total`; + - over-command → wheels slip/lock, realised `ΣFx < Fx_total` = physical grip loss. + +--- + +## 6. Low-μ patches (spatially varying friction) + +### 6a. 1-D bands along the road (`friction_map`) + +Pass `friction_map` as a list of `(x_start, x_end, mu)` along the road (world-x; straight road +⇒ s=x). Outside the patches, `base_mu` applies. At each patch boundary the grip transitions +linearly over **1.0 m** (entry and exit); `x < x0 − 1` or `x > x1 + 1` uses `base_mu`. +Overlapping patches take the **minimum** μ. Friction is per-wheel by contact x, so the front +axle enters a patch before the rear (real time lag). + +```python +plant = VDSimPlant( + config="ioniq5_awd", + base_mu=0.9, + friction_map=[(40.0, 60.0, 0.5)], # μ=0.5 ice patch from x=40 m to x=60 m + control_dt=0.05, substep_dt=5e-4, +) +``` + +### 6b. 2-D polygon patches (`friction_map_2d`) + +For arbitrary regions in the ground plane (e.g. a localized ice spot offset from the lane centre), +pass `friction_map_2d` — a list of `{"polygon": [(x0,y0), ...], "mu": 0.5}` entries (world +frame, ISO 8855). Each polygon needs ≥3 vertices; edges close implicitly (last → first). + +Per-wheel μ is evaluated at the wheel contact `(x, y)`. Inside a polygon: patch μ. Outside: +linear blend to `base_mu` over **1.0 m** from the polygon boundary (global `blend_distance`). +Overlapping patches take the **minimum** μ (most restrictive grip). Mutually exclusive with +`friction_map` (pass one or the other). + +```python +plant = VDSimPlant( + config="ioniq5_awd", + base_mu=0.9, + friction_map_2d=[{ + "polygon": [(80.0, -2.0), (120.0, -2.0), (120.0, 2.0), (80.0, 2.0)], + "mu": 0.5, + }], + control_dt=0.05, substep_dt=5e-4, +) +``` + +Low-level access: `vdsim.create_polygon_friction_ground(z, base_mu, patches, blend_distance=1.0)`. + +--- + +## 7. Steering actuator lag (optional) + +By default the plant applies your road-wheel steer command **instantaneously** (ZOH at +`control_dt`) — the right baseline for MPC. For robustness studies you can add a **first-order +lag** on the realized steer angle using `VehicleParams.steer_deadtime_s` [s] as the time +constant τ: + +\[ +\tau \dot\delta_\text{real} + \delta_\text{real} = \delta_\text{cmd} +\] + +(discretized with an exact exponential step each `substep_dt`). Set `steer_deadtime_s = 0` +(default) to recover the instantaneous path. + +`VDSimPlant` does not expose `steer_deadtime_s` in its constructor yet. Use one of these +manual workflows **before** the session is created: + +**A — add to the vehicle YAML** (simplest): + +```yaml +steer_deadtime_s: 0.05 # [s] 1st-order steer lag (τ) +``` + +**B — load, patch, write a temp config:** + +```python +import tempfile +import vdsim +from vdsim_plant import VDSimPlant, resolve_vehicle_config + +vp_path = resolve_vehicle_config("ioniq5_awd") +vp = vdsim.VehicleParams.from_yaml(str(vp_path)) +vp.steer_deadtime_s = 0.05 +with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as f: + tmp = f.name +vp.to_yaml(tmp) +plant = VDSimPlant(config=tmp, control_dt=0.05, substep_dt=5e-4) +``` + +**Observe the delay** — step response after a steer step: + +```python +obs = plant.reset(state0=[0, 0, 0, 16.7, 0, 0]) +r0 = obs["r"] +for _ in range(20): + obs = plant.step([0.05, 0.0]) # δ_cmd = 0.05 rad +print(obs["r"] - r0) # yaw rate builds more slowly than with τ=0 +``` + +Compare against `steer_deadtime_s: 0` (or default) on the same manoeuvre; larger τ ⇒ slower +`obs["r"]` / lateral response for the same `delta` command. + +--- + +## 8. Closed-loop with your controller (drop-in) + +The plant replaces a Python bicycle plant with no controller change. Pattern: + +```python +plant = VDSimPlant(config="ioniq5_awd", base_mu=0.9, + friction_map=[(40.0, 60.0, 0.5)], + control_dt=0.1, substep_dt=1e-3) # control_dt = your MPC sample +obs = plant.reset(state0=[0, 0, 0, 16.7, 0, 0]) +for k in range(N): + # build the MPC state from obs (ISO 8855, same convention) + x = [obs["X"], obs["Y"], obs["psi"], obs["vx"], obs["vy"], obs["r"]] + delta, Fx = mpc.solve(x) # your acados MPC — UNCHANGED + obs = plant.step([delta, Fx]) +``` + +To replace `~/vla_design/sim/closed_loop_sim.py`'s plant: keep the MPC; swap the plant object +for `VDSimPlant` and feed `step([delta, Fx])` instead of the bicycle `plant_deriv/rk4`. + +Note: `control_dt` (the MPC sample, held ZOH) must be **≥** `substep_dt`, and `substep_dt` +must divide `control_dt` evenly. The MPC at dt=0.1 with `control_dt=0.1, substep_dt=1e-3` runs +100 internal substeps per control step. + +--- + +## 9. Parameter file — `ioniq5_awd.yaml` + +The bundled vehicle preset (`configs/vehicles/ioniq5_awd.yaml`), public Ioniq5-class: + +```yaml +mass: 2359.0 +mass_sprung: 2200.0 +inertia_diag: [1200.0, 4200.0, 3400.0] # [Ixx, Iyy, Izz]; Izz=3400 is the yaw inertia +wheelbase: 2.97 +cg_to_front: 1.17 # lf +cg_to_rear: 1.80 # lr +track_front: 1.635 +track_rear: 1.635 +cg_height: 0.58 +wheel_radius_nominal: 0.338 +drive_type: AWD +drive_split_front: 0.5 # 50/50 front/rear Fx split (drive AND brake) +plant_path: true # bypass the vehicle low-speed kinematic blend +differential: Open +steering_ratio: 14.0 +aero_drag_coeff: 0.28 +frontal_area: 2.45 +tire_yaml: parts/tire/ioniq5_pac2002.yaml # the tyre sidecar (below) +``` + +Tyre `configs/parts/tire/ioniq5_pac2002.yaml` (MF2002 `.tir` backend, LuGre OFF, combined-slip +ON). Unlike the old MF96 (constant B/C/E), the MF2002 coefficients are **load-dependent**, so +cornering stiffness and peak μ change with Fz — the load-transfer / limit behaviour an MPC +robustness study needs: + +```yaml +backend: mf2002 +tir_path: ioniq5_pac2002.tir # Pacejka-2002 coefficient file (next to this yaml) +mu_nominal: 0.9 +Fz_nominal: 5764.0 +combined_slip_enabled: true +lugre: { enabled: false } +``` + +The `.tir` (`configs/parts/tire/ioniq5_pac2002.tir`, public-synthetic — no measured data) holds +the Pacejka-2002 coefficients. The load-shaping ones: + +| coeff | role | +|---|---| +| `PKY1`, `PKY2` | cornering-stiffness magnitude + its **saturation with load** (Ca concave in Fz) | +| `PDY1`, `PDY2` | lateral peak factor + **peak-μ fall with load** (`μ_peak ↓` as Fz↑) | +| `PCY1`, `PEY1` | lateral shape / curvature | +| `PKX1`, `PDX1`, `PDX2`, `PCX1` | longitudinal stiffness, peak, load sensitivity, shape | + +### Making your own vehicle + +Copy `ioniq5_awd.yaml`, edit mass/geometry. The MF2002 cornering stiffness per wheel is +`Kya(Fz) = PKY1 · FNOMIN · sin( 2·atan( Fz / (PKY2·FNOMIN) ) ) · LKY` — load-dependent, so a +**single** `.tir` reproduces different front/rear axle stiffness automatically from each axle's +static Fz (no per-axle file needed). To target known axle stiffnesses, pick `PKY1` (overall +magnitude) and `PKY2` (where it saturates) so that `2·Kya(Fz_front)=Ca_f` and +`2·Kya(Fz_rear)=Ca_r`. + +Worked example (Ioniq5, this preset): `PKY1=-24.6, PKY2=2.56` give +`Kya(7011 N)=109.3k` → axle `Ca_f≈218k` and `Kya(4558 N)=79.5k` → axle `Ca_r≈159k` +(targets 220k / 160k, ~1 %). Because `Kya` is concave, `Kya(2·Fz0)/Kya(Fz0)=1.435` (< 2 = +saturating, not linear), and `PDY2=-0.10` drops peak μ from 0.90 to 0.81 at 2·Fz0. + +--- + +## 10. API reference + +```python +VDSimPlant(config="ioniq5_awd", # vehicle preset name or path + friction_map=None, # [(x0, x1, mu), ...] 1-D bands along x + friction_map_2d=None, # [{"polygon": [(x,y),...], "mu": ...}, ...] + base_mu=0.9, # (0, 1.2] + control_dt=0.05, # s, one step() period (ZOH); >= substep_dt + substep_dt=5e-4) # s, internal fixed integration step; divides control_dt + +plant.reset(state0=[X, Y, psi, vx, vy, r]) -> obs # seeds wheel spin = vx / R_eff +plant.step([delta_rad, Fx_total_N]) -> obs # advance one control period +``` + +Inputs are validated; bad ones raise a clear `ValueError`/`TypeError` (see Troubleshooting). + +--- + +## 11. What's validated (so you can trust it) + +Gated by `ctest -R VlaPlant`; the current full-suite result and measurement +configuration are tracked in [Validation](VALIDATION.md): +- **Dry handling = linear bicycle within ~4 %** (steady yaw-rate gain vs the single-track model + with the same `Caf/Car`) — your bicycle results carry over. Checked for both the analytic-B + tyre and the real MF2002 `.tir`. +- **Load-dependent tyre (MF2002)**: cornering stiffness concave in Fz + (`Kya(2Fz0)/Kya(Fz0)=1.435 < 2`) and peak μ falls with load (`0.90→0.81` at 2·Fz0) — the + load-transfer limit behaviour MF96 could not represent. +- **Friction circle held**: `‖[Fx,Fy]‖ ≤ mu·Fz` every step; over-command ⇒ real slip + the + vehicle cannot deliver the commanded Fx (grip loss), and a brake never spins a wheel + backwards. +- **Force ↔ accel consistency**: `ΣFy ≈ m·ay`. +- **Throttle bypass**: `Fx` is applied as torque to the wheel spin, not a pedal map. +- **Speed**: a 5 s trajectory runs in ~25 ms (≫ real time → cheap sweeps). + +This is an independent-of-the-controller mid-fidelity plant; the MF2002 load-dependent tyre ≠ +your bicycle internal (linear-Ca) model is an *intended* plant–model mismatch — a stronger +robustness story than a matched plant, and exactly the Fz-driven nonlinearity a σ_Fz / +chance-constraint study wants to stress. + +--- + +## 12. Tips & gotchas + +- **Determinism**: no RNG; identical inputs ⇒ identical outputs. Keep `substep_dt` fixed for + reproducible figures. +- **Two-tier dt**: `control_dt` = your control rate; `substep_dt` ≤ 1 ms (wheel spin is stiff). + Coarsening `substep_dt` can make hard-brake locking inaccurate. +- **Frames**: `wheel.Fx/Fy` are in the **contact (wheel) frame** — correct for the friction + circle; do not re-rotate by steer. +- **Low-speed**: the plant uses a tyre VLOW floor (no kinematic-blend fade), so the saturating + tyre stays active even near a stop. +- **Beta**: report rough edges — API and defaults may be tuned from your usage. + +--- + +## 13. Troubleshooting + +| symptom | cause / fix | +|---|---| +| `ValueError: friction_map[i]: x0>=x1` | patch start must be < end | +| `ValueError: ... mu=... outside (0, 1.2]` | friction must be in (0, 1.2] | +| `ValueError: substep_dt must divide control_dt evenly` | pick e.g. control_dt=0.05, substep_dt=5e-4 (×100) | +| `ValueError: step(u): u must be [delta_rad, Fx_total_N]` | pass a 2-element `[delta, Fx]` | +| `FileNotFoundError: ... config` | use a bundled name (`ioniq5_awd`) or an absolute YAML path | +| `import vdsim` fails | build first (`cmake --build build -j`) and add `build/python` to `sys.path` | +| car won't slow under braking | `Fx` is a force intent; on low μ the realised brake force is capped at grip — expected | + +--- + +## 14. Planned features (P2 — next release) + +Based on BETA #1 customer feedback (`docs/PLANT_CUSTOMER_FEEDBACK.md`): + +### Split-μ friction map (left/right different grip) +**`friction_map_left=[(x0, x1, mu), ...], friction_map_right=[...]`** +- C++ backend exists (`create_split_mu_ground`); not yet exposed to Python plant API. +- Use case: ABS/ESC algorithm testing, split-μ braking scenarios. + +--- + +## 15. Related documents + +- Customer feedback and validation: [`docs/PLANT_CUSTOMER_FEEDBACK.md`](PLANT_CUSTOMER_FEEDBACK.md) +- Full spec and design decisions: [`docs/design/VLA_THESIS_PLANT.md`](design/VLA_THESIS_PLANT.md) +- Computational cost per dynamics level: [`docs/CATALOG_AND_PHYSICS.md`](CATALOG_AND_PHYSICS.md#computational-cost-per-step) +- Bundled example: `examples/vla_plant_demo.py` diff --git a/docs/assets/demo_compare_runs.gif b/docs/assets/demo_compare_runs.gif new file mode 100644 index 00000000..45915219 Binary files /dev/null and b/docs/assets/demo_compare_runs.gif differ diff --git a/docs/assets/demo_compare_runs_preview.png b/docs/assets/demo_compare_runs_preview.png new file mode 100644 index 00000000..efe54f66 Binary files /dev/null and b/docs/assets/demo_compare_runs_preview.png differ diff --git a/docs/assets/demo_grip_loss.gif b/docs/assets/demo_grip_loss.gif index cb7bb208..c1a2306c 100644 Binary files a/docs/assets/demo_grip_loss.gif and b/docs/assets/demo_grip_loss.gif differ diff --git a/docs/assets/demo_grip_loss_preview.png b/docs/assets/demo_grip_loss_preview.png new file mode 100644 index 00000000..7bbe97e6 Binary files /dev/null and b/docs/assets/demo_grip_loss_preview.png differ diff --git a/docs/design/CURSOR_USAGE.md b/docs/design/CURSOR_USAGE.md deleted file mode 100644 index 7f48150c..00000000 --- a/docs/design/CURSOR_USAGE.md +++ /dev/null @@ -1,69 +0,0 @@ -# Cursor 사용 지침 — Claude 토큰 절감 (VDSim) - -작성 2026-06-05. 목적: VDSim 개발에서 Claude(Anthropic) 한도를 아끼기 위해 Cursor 병행. -전 에이전트 공통 원칙은 글로벌 `~/.claude/CLAUDE.md` "Cursor 위임" 섹션. 이 문서는 VDSim 프로젝트별 상세. -저장 위치(권장): `docs/design/CURSOR_USAGE.md`. - -## 0. 과금 현실 (2026-06 웹확인, 변동 가능 — 가입 시점 재확인) -- Cursor 플랜: Hobby(Free) / Pro $20 / Pro+ $60 / Ultra $200. 유료플랜은 가격만큼 크레딧 풀 포함($20/$60/$200). -- **Auto mode = 유료플랜 쿼터 내 무제한** (Cursor 가 모델 선택). frontier 모델(Sonnet/Opus) 수동선택 시 크레딧 소모, 초과분 API 종량제. -- 버킷 분리: Cursor 작업은 Anthropic 한도가 아니라 Cursor 쿼터로 빠짐. -- 절감 핵심: 단순작업을 Cursor Auto/Composer 로 빼면 Anthropic 한도 절감 + Cursor 추가비용 0. Opus 를 Cursor 에서 쓰면 버킷만 다르고 비용은 비슷 → 의미 적음. -- (확인 필요) 정확한 플랜·단가·크레딧 정책: cursor.com/docs/models-and-pricing. - -## 1. Task 라우팅 rubric (VDSim 구체) -| 작업 유형 | 도구·모델 | 근거 | -|---|---|---| -| 인터페이스/추상클래스 stub, SubsystemContext·DelayLine 보일러플레이트 (#159) | Cursor Composer/Auto | 패턴 정해짐, ctest 검증 | -| default 모듈 구현 (#160) | Cursor Composer/Auto | 기존 거동 복제, 정답 명확 | -| 테스트 케이스 작성, 포맷팅, 이름변경, mechanical 일괄수정 | Cursor Composer/Auto | 반복·기계적 | -| 코어→모듈 경유 리팩터 (#161) 의 기계적 적용 부분 | Cursor (단 default==현재거동 판정은 Claude) | 패턴 적용 | -| 물리 디버깅(타이어/적분기 발산), 수치 정합, 아키텍처 결정, FMI/cosim(VDS1) 프로토콜 설계 | **Claude Opus** | 불확실·추론 핵심 | -| 모호한 spec 해석, `V0.2_SUBSYSTEMS.md` 설계 변경, novelty 판단 | **Claude Opus** | 판단 | - -판단 한 줄: 정답이 거의 정해졌고 `ctest` 로 검증되면 Cursor, 아니면 Claude. - -## 2. 모델 선택 (Cursor 내) -| 작업 | 권장 모델 | 비용 | -|---|---|---| -| 인라인 편집, 빠른 리팩터, 보일러플레이트 | Composer (Cursor 자체, 약 4x 빠름) | Auto 무제한/저렴 | -| 긴 agent 런 + 중간 판단 | Sonnet (Opus 대비 저비용, 품질 근접) | 크레딧 소모 | -| 모델 고민 회피 | Auto (Cursor 가 선택) | 0 (포함, 무제한) | -| 진짜 어려운 아키텍처 | Opus — 단 이건 Claude Code 에서 하는 게 정석 | 크레딧 큼 | - -**위임 기본 = `--model composer-2.5` 고정 (웬만하면 이걸로).** auto 는 Cursor 가 모델선택해 더 비싼 모델 고를 수 있어 회피. frontier(Sonnet/Opus)는 꼭 필요할 때만 수동선택. 크레딧 소진 대시보드 주시. -(확인 필요) 모델명·버전·벤치는 시점 따라 변동 — cursor.com/docs/models-and-pricing. - -## 3. 핸드오프 / 일관성 -- 진실원천: `docs/HANDOFF.md`(툴 독립적 진행상황) + `docs/design/V0.2_SUBSYSTEMS.md`(설계 spec). Cursor·Claude 둘 다 세션 시작 시 필독. -- 규약 미러링: `CLAUDE.md` 핵심 규약을 `.cursor/rules/`(project rules)로 미러 → Cursor 자동 적용. (확인 필요: .cursor/rules 포맷 — cursor.com/docs) -- MCP: Cursor CLI 가 desktop 과 `mcp.json`·rules·auth 공유. 기존 MCP 재사용 가능. - -## 4. 가드레일 (CLAUDE.md 와 동일 강제) -- 검증 우선: 외부 사실/API/수치 추측 금지, 확인 후 기술. -- 규약: wheel FL=0/FR=1/RL=2/RR=3, ISO 8855 RH, YAML 파라미터. - - **estimation noise = Simon `Q=process/R=measurement`.** (※ 원 지시의 "Thrun R=process" 는 글로벌 정정으로 폐기 — VDSim estimator(`estimator_in_loop` 등)는 Simon. SLAM 교재 맥락에 한해 Thrun.) -- 보안: 현대 산학 실측 tire 데이터 대외비 — 값/세부 커밋 금지, generic 지칭만. -- git: 명시 요청 없이 push/force/tag 이동 금지. commit 메시지 규약 준수. -- 합격기준: 변경 후 **ctest 191 green 유지.** 특히 #161 코어 리팩터는 default == 현재거동(재baseline 금지). - -## 5. 워크플로 체크리스트 -1. 세션 시작: `docs/HANDOFF.md` + 관련 `docs/design/*` 읽기. -2. 작업 분류: 위 rubric 으로 Cursor vs Claude 결정. -3. Cursor 위임: `~/bin/cursor_delegate.sh "<지시 + 가드레일>" <작업유형> []` (내부 composer-2.5 고정 + usage 자동로깅 → `~/cursor_eval/deleg.log`; 작업유형 예 boilerplate/test/refactor) 또는 IDE Composer. 직접 `cursor-agent -p --force --trust --model composer-2.5 "..."` 도 가능하나 deleg.log 미기록. -4. 빌드·검증: `cmake --build build -j && (cd build && ctest --output-on-failure)` → 191 green 확인. -5. commit (규약 준수, push 안 함). -6. `docs/HANDOFF.md` 갱신 → 다음 세션·툴이 이어받게. - -## 6. Claude ↔ Cursor 충돌 방지 -- 동시 편집 금지: 같은 파일·이슈를 두 툴이 동시에 만지지 말 것. 이슈/파일 단위로 분리. -- 브랜치: 위험·대형 작업은 브랜치 분리. 기계적 위임은 작은 단위 commit. -- 재빌드 경쟁: 한 툴이 빌드 중이면 다른 툴 빌드 회피(`build/` 충돌). -- 동기화: 작업 끝나면 HANDOFF.md 갱신 → 다음 툴이 최신 상태 인지. -- 책임: Cursor 산출물도 최종 검증(ctest/리뷰)은 Claude. 위임 ≠ 방치. - ---- -CLI vs SDK (둘 다 실재 — 2026-06 확인): -- **Cursor CLI (`cursor-agent`)**: 셸 헤드리스(`-p`), 구독쿼터 내 **Auto mode 무제한**, 언어 무관. → **토큰 절감 위임은 이걸로.** -- **Cursor SDK (`@cursor/sdk`)**: TypeScript, 2026-04 public beta, **토큰 종량제**, Python 공식 미지원(커뮤니티/REST). subagents·hooks·cloud VM 등 프로그래밍적 에이전트 인프라용. → 종량제라 절감 목적엔 부적합, 정교한 자동화 필요할 때만. -주의(확인 필요): 제품명/플래그/과금은 실행 시점 `cursor.com/docs/cli`·`models-and-pricing`·`changelog/sdk-release` 재확인. diff --git a/docs/design/GUI_V2_REQUIREMENTS.md b/docs/design/GUI_V2_REQUIREMENTS.md new file mode 100644 index 00000000..be7beee5 --- /dev/null +++ b/docs/design/GUI_V2_REQUIREMENTS.md @@ -0,0 +1,408 @@ +# GUI v2 — requirements catalog (restart baseline) + +Status: **superseded for product direction** by [`GUI_V3_REQUIREMENTS.md`](GUI_V3_REQUIREMENTS.md) (2026-06-21). +Keep §3.1 as the **v1 parity checklist** for v3 launch gate. +v2 incremental `/v2/app.html` plan is **cancelled**. +Wire contract: [`../gui_architecture.md`](../gui_architecture.md) (REST + SSE, unchanged) +Catalog model: [`PARTS_CATALOG.md`](PARTS_CATALOG.md) + +--- + +## 0. Restart principles + +| # | Principle | +|---|-----------| +| R0 | **Not game UI** — Notion/Linear-like light OSS; teaching + engineering credibility | +| R1 | **Wire contract frozen** — frontend depends on REST/SSE schema only, not Python internals | +| R2 | **Three layers stay separate** — (1) vehicle build, (2) experiment/scene, (3) compare/validation | +| R3 | **Incremental migration** — v2 surfaces behind flag/route; v1 `app.js` runs until parity | +| R4 | **Offline-first** — self-host fonts/icons (`gui/vendor/`); no CDN hard dependency | +| R5 | **mkdocs help** — `[?]` on non-obvious labels; no long prose in modals | +| R6 | **No physics in browser** — server resolves catalog → params; GUI edits manifests + session overrides | + +--- + +## 1. Product positioning + +| Item | Requirement | +|------|-------------| +| Role | Browser **authoring + Play** for VDSim plant; not CAD, not tire coefficient workshop | +| Differentiator | Compact physics state → local Three.js reconstruction (not video stream) | +| Audience | Maintainer, external evaluators, Formula Student, autonomous/ADAS students | +| Success | ≤5 clicks from fleet → built vehicle → run or compare; citeable ISO numbers | + +--- + +## 2. Personas & jobs-to-be-done + +| Persona | Primary job | v2 must support | +|---------|-------------|-----------------| +| **Maintainer** | Ship credible demos without wire churn | Stable API, fast iteration, ctest-backed catalog | +| **External customer** | Evaluate plant without install | Browser-only, export YAML, clear limits documented | +| **Formula Student** | Team car spec → step-steer/skidpad numbers | Build-sheet → compare modal → CSV/metrics | +| **Autonomous student** | Realistic plant + cosim | Fleet + UDP targets, repeatable scenarios | + +--- + +## 3. Information architecture — **three composition layers** (v1 already has all three) + +v1 is **not** “build-sheet only”. The GUI already separates: + +| Layer | Korean (팀 내부) | VDSim object | v1 surface | v2 doc gap (fixed below) | +|-------|------------------|--------------|------------|---------------------------| +| **A. Vehicle build** | 차량 구성 | `blueprint` + catalog `parts` | Modal → **Assembly** | Was over-emphasized as sole MVP | +| **B. Run / scene compose** | 지도·실험 구성 | `scene.yaml`: map, path, fleet spawn, road, comms | **Setup bar** (4 tabs) + sidebar fleet | Was “v2.1 carry-over” one-liner only | +| **C. Analyze** | 검증·비교 | ISO maneuver on N manifests | **Compare modal** (⊟) | Listed but not inventory’d | + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ SIDEBAR │ MAIN VIEWPORT │ TELEMETRY (run) │ +│ · Sim transport │ · Three.js scene │ · pose / tire / │ +│ · Fleet tree │ · path edit (top view) │ controls │ +│ · Infra sensors │ · spawn drag │ │ +│ · Minimap │ · stunt/terrain mesh │ │ +├──────────────────┴─────────────────────────┴────────────────────┤ +│ SETUP BAR — Run composition (layer B) │ +│ [Map] [Vehicle] [Options] [Modules] · scenario load/save │ +│ · Road μ/grade/bank · xodr / terrain · fleet spawn rows │ +│ · Path preset / waypoints · cosim UDP · IO targets · C++ MW │ +├──────────────────────────────────────────────────────────────────┤ +│ MODALS │ +│ · Vehicle Edit → Assembly | Parts lib | Session tune (layer A) │ +│ · Compare ISO metrics (layer C) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +| Surface | In v2.0 new shell? | Notes | +|---------|-------------------|-------| +| **Build-sheet** (Assembly) | **Yes — rewrite** | Danawa rows + schematic preview | +| **Setup bar** (Map/Vehicle/Options/Modules) | **No — v1 embed or link** until v2.1 | Full feature list §3.1 | +| **Play viewport + minimap** | **No — v1** until v2.2 | Regression = unacceptable | +| **Compare modal** | **No — v1** | | +| **Session tune tabs** | **No — v1** | Chassis/tire/K&C plots | + +--- + +## 3.1 v1 implemented inventory (baseline — **parity checklist**) + +Everything below exists in `gui/app.html` + `gui/static/app.js` today. v2 restart **must not drop** these without an explicit deprecation entry. + +### A. Vehicle build (차량 구성) + +| ID | Feature | UI location | API / backend | +|----|---------|-------------|---------------| +| V-01 | Fleet vehicle **+** add from blueprint preset | Sidebar Vehicles | `POST /api/setup` fleet_add | +| V-02 | Per-vehicle **Edit** modal | Tree → Edit | `openModal(vid)` | +| V-03 | **Assembly** build-sheet (Danawa rows, categories) | Modal Assembly | `/api/catalog/assembly` | +| V-04 | Part cards + install + resolved stats | Assembly center/right | `assembly.py` resolve | +| V-05 | Blueprint / Level selectors + recommended chips | Assembly top | `POST /api/setup` | +| V-06 | **Parts library** browse/import | Modal Parts | `/api/catalog`, custom kin | +| V-07 | **Session tune**: chassis, suspension, brake, steer, drivetrain, tire | Modal tune dropdown | `/api/vehicle`, `/api/tire`, fields | +| V-08 | Suspension **K&C plots** (L3/L4) | Suspension tab | `/api/suspension/kc` | +| V-09 | Actuator + **Sensors** workshop | Modal tabs | `/api/actuator`, `/api/sensors` | +| V-10 | Export / register blueprint | Assembly actions | export API | +| V-11 | Per-vehicle level, vehicle stem, tire, L3 susp stems | Fleet + modal | fleet spec normalize | + +### B. Run / scene composition (지도·실험·플릿 구성) + +| ID | Feature | UI location | API / backend | +|----|---------|-------------|---------------| +| S-01 | **Scenario** load / save / export / import | Setup bar header | `/api/scene`, save modal | +| S-02 | Draft sync badge + Play → `runs/live/` | Setup status | `POST /api/setup`, `/api/control` | +| S-03 | **Map tab**: road μ, grade°, bank°, v_target | Setup → Map | `setup.road`, `v_target` | +| S-04 | **OpenDRIVE** `.xodr` load | Setup → Map | `/api/map/load` | +| S-05 | **Terrain** `.obj` load + clear | Setup → Map | `/api/terrain/load`, `/api/terrain/clear` | +| S-06 | **Vehicle tab**: fleet spawn rows (x0,y0,yaw0,vx0…) | Setup → Vehicle | fleet fields in setup | +| S-07 | **Spawn drag** in 3D top view | Viewport + hint | updates fleet pose | +| S-08 | **Path preset**: figure-8 / straight / custom | Setup → Options | `path_preset`, `path_pts` | +| S-09 | Waypoint **table** + add row | Setup → Options | `path_pts` | +| S-10 | **Path edit mode** on ground (drag ●, add, Del) | Cam bar “Edit track” | posts path to setup | +| S-11 | **Minimap** trail | Sidebar canvas | `minimap.js` + SSE | +| S-12 | **Cosim attach** host + cmd/state ports | Setup → Options | cosim start/stop | +| S-13 | **IO / telemetry targets** | Setup → Options comms | `/api/io/targets` | +| S-14 | **C++ module workshop** (build & register) | Setup → Modules | `/api/io` module check | +| S-15 | **Infra sensors** tree (authoring) | Sidebar | `infra_sensors` in setup | +| S-16 | Multi-vehicle **+ add 2nd** | Setup Vehicle | `fleet_add` | +| S-17 | **Stunt** ground viz (ramp / loop) when scene has `stunt:` | 3D overlay | scene stunt block | +| S-18 | Sim cfg load/save (sidebar) | SIM card | `/api/runconfig` | + +### C. Play runtime (실행·시각화) + +| ID | Feature | UI location | +|----|---------|-------------| +| P-01 | Play / Stop / Reset | Sidebar | +| P-02 | Autopilot vs **manual** drive (+ touch manbar) | Sidebar + manbar | +| P-03 | Realtime **time scale** slider | Sidebar | +| P-04 | Cam **orbit / chase / top** | Cam bar | +| P-05 | SSE ~60 Hz state → fleet meshes | `#view` Three.js | +| P-06 | Wheel **force vectors** Fx/Fy/Fz | On wheel triad | +| P-07 | Telemetry panel (pose, tire, controls) | Right aside | +| P-08 | Kinematics warnings | Sidebar `#kin_warnings` | +| P-09 | L5 stunt pose (z, roll, pitch) | telemetry + mesh | + +### D. Analyze / compare (검증) + +| ID | Feature | UI location | API | +|----|---------|-------------|-----| +| A-01 | **Compare vehicles** modal (⊟) | Sidebar | `/api/compare` | +| A-02 | Blueprint multi-select | Compare modal | fleet blueprints | +| A-03 | ISO maneuver picker (step-steer, …) | Compare | `vdsim_compare` | +| A-04 | Yaw-rate **overlay chart** (SVG) | Compare results | trace in response | +| A-05 | **Δ% table** vs baseline | Compare results | compare CSV logic | + +### E. v1 requested / known gaps (HANDOFF — not done) + +| ID | Feature | Status | +|----|---------|--------| +| G-01 | **Multi-fleet 3D** — N vehicles simultaneous render (#158) | Partial (spawn OK, demux polish) | +| G-02 | **Dockable / resizable** panels; drop monolithic modal | Design review — deferred to v2+ | +| G-03 | Global nav **Setup | Sim | Analyze** | Not implemented | +| G-04 | GUI **terrain Play** polish (chase on mesh, spawn on surface) | v0.5.2 deferred | +| G-05 | GUI **stunt authoring** (not just render) | v0.5.2 deferred | +| G-06 | In-browser **`.tir` import** | v0.5.2 deferred | +| G-07 | Belt transient **force arrow** viz fix | open | +| G-08 | **powertrain** slot in assembly (split blueprint) | catalog gap | +| G-09 | Blueprint switch **confirm** (silent parts wipe) | fix in review commit | + +--- + +## 3.2 Why the first v2 draft felt incomplete + +| You expected (v1 dev) | First `GUI_V2_REQUIREMENTS` draft | Correction | +|------------------------|-----------------------------------|------------| +| 차량 구성 (assembly) | BS-* detailed | ✓ kept | +| 지도·terrain·road 구성 | One row “EX-01” | **§3.1 S-03–S-05** | +| 경로·waypoint 구성 | One row “EX-01” | **§3.1 S-08–S-10** | +| 플릿 spawn·멀티차량 | FL-* thin | **§3.1 S-06–S-07, S-16** | +| 시나리오 save/load | Mentioned | **§3.1 S-01** | +| Compare 대시보드 | CP-* thin | **§3.1 A-01–A-05** | +| Session tune (타이어·서스) | “v1 carry” | **§3.1 V-07–V-09** | +| Module workshop | Missing | **§3.1 S-14** | + +**v2.0 scope stays “new build-sheet shell”** — but **full product requirements = v1 parity list + BS/PV rewrite**. Setup/map/vehicle composition is **layer B**, not optional. + +--- + +## 4. Functional requirements (by feature) — v2 **delta** only + +### 4.1 Vehicle build-sheet (MVP core) + +| ID | Requirement | Priority | Acceptance | +|----|-------------|----------|------------| +| BS-01 | Entry: Fleet vehicle → **Build** opens modal, **Assembly** tab default | P0 | 1 click from tree | +| BS-02 | **Preset-first**: recommended blueprints as chips; then per-slot edit | P0 | FS/sedan/sports chips | +| BS-03 | Slot list grouped: Body & chassis / Powertrain / Grip / Control | P0 | Matches `assembly.py` categories | +| BS-04 | Row = installed part + **change**; click row → part cards for that slot | P0 | Single-click install (not double-click) | +| BS-05 | **Resolved stats** panel: mass, WB, CG h, μ, drive — from physics resolve | P0 | Not hand-typed | +| BS-06 | Hover card → **Δ stats** preview; click → install | P1 | Debounced `/api/catalog/assembly?preview=` | +| BS-07 | `build_complete` = required slots only (optional corner tires excluded) | P0 | L2 default blueprint true | +| BS-08 | Level L1–L5 selector updates slots (L3+ chassis slots appear) | P0 | `slots_for_level` | +| BS-09 | Compat badges on cards (`front_chassis` schema, axle hint) | P1 | `part_compat` | +| BS-10 | Footer: **Sync draft** + **Export blueprint**; link to Setup for experiment | P0 | No scene editing in build-sheet | +| BS-11 | Blueprint change: **confirm** before replacing custom `parts` | P0 | Dialog lists slots that will reset | + +### 4.2 Build-sheet preview (schematic, not 3D car) + +| ID | Requirement | Priority | Acceptance | +|----|-------------|----------|------------| +| PV-01 | **No** runtime vehicle mesh in build-sheet modal | P0 | Removed Three.js side preview | +| PV-02 | **L1–L2**: textbook side view — sprung mass, CG, WB, wheels | P0 | SVG | +| PV-03 | **L3**: 7-DOF schematic — spring/damper per corner | P0 | SVG | +| PV-04 | **L4–L5**: Adams-style linkage side view (ISO x–z) from kin YAML | P0 | `/api/suspension/schematic` | +| PV-05 | Active chassis slot highlights matching axle panel | P1 | front/rear highlight | +| PV-06 | Preview updates on hover Δ stats | P1 | Same data as stats panel | + +### 4.3 Session tune (vehicle modal, non-assembly tabs) + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| ST-01 | Chassis / tire / brake / steer / drivetrain field forms from schema | P1 | v1 `fields.js` | +| ST-02 | Overrides are **session** only; assembly note points to build-sheet | P1 | | +| ST-03 | Suspension K&C plots at L3/L4 | P2 | existing API | +| ST-04 | Engine tab stub or hidden until powertrain part wired | P2 | | + +### 4.4 Experiment / scene (Setup bar — layer B, **v1 parity**) + +Full IDs: **§3.1 S-01–S-18**. v2.1 re-skins this bar; v2.0 must keep v1 route working. + +| ID | Requirement | Priority | v2 phase | +|----|-------------|----------|----------| +| EX-01 | Scenario load/save/export/import | P0 | v1 until v2.1 | +| EX-02 | Map: μ, grade, bank, v_target | P0 | v2.1 UX pass | +| EX-03 | xodr + terrain obj load/clear | P1 | v2.1 | +| EX-04 | Path preset + waypoint table + 3D path edit | P0 | v2.1 | +| EX-05 | Fleet spawn table + drag in view | P0 | v2.1 | +| EX-06 | Cosim attach + IO targets | P1 | v1 | +| EX-07 | C++ module workshop tab | P2 | v1 | +| EX-08 | Infra sensors authoring tree | P2 | v1 | +| EX-09 | Stunt scene viz (ramp/loop) | P2 | v1 render; G-05 authoring later | +| EX-10 | Link from build-sheet footer → **scroll/highlight Setup** | P1 | v2.0 | + +### 4.5 Play / runtime visualization + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| PL-01 | SSE state @ ~60 Hz → Three.js fleet + chase/top/orbit | P0 | v1 | +| PL-02 | Wheel force vectors (Fx/Fy/Fz) offset outside tread | P1 | triad common origin | +| PL-03 | Minimap trail, spawn hints, path edit mode | P1 | v1 | +| PL-04 | Telemetry panel (Fz, Ft, slip, …) | P1 | v1 | +| PL-05 | WebGPU optional; WebGL2 fallback | P2 | architecture doc | + +### 4.6 Compare / validation + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| CP-01 | Select N fleet blueprints → ISO maneuver (step-steer, …) | P1 | `vdsim_compare` | +| CP-02 | Overlay chart + Δ% table vs baseline | P1 | v1 | +| CP-03 | Separate from build-sheet (not embedded) | P0 | IA rule | + +### 4.7 Fleet management + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| FL-01 | Add vehicle from blueprint preset | P0 | | +| FL-02 | Multi-vehicle fleet, live_vid selection | P1 | v1 | +| FL-03 | Per-vehicle color, spawn pose | P1 | v1 | +| FL-04 | Remove vehicle (sim stopped) | P1 | v1 | + +### 4.8 Parts library & import + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| PT-01 | Browse by type: body, aero, ride, chassis, tire, … | P1 | | +| PT-02 | Import kin YAML → `gui_custom` | P2 | | +| PT-03 | Read-only YAML preview in UI | P2 | | +| PT-04 | Register blueprint from current fleet build | P2 | | + +### 4.9 Help & documentation + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| HP-01 | `.v2-help` icon → mkdocs (`docHref`) | P0 | | +| HP-02 | Slot row help → PARTS_CATALOG glossary | P1 | | +| HP-03 | Kinematics warnings → doc link, amber in conn bar | P1 | v1 | + +### 4.10 Cosim / external IO + +| ID | Requirement | Priority | Notes | +|----|-------------|----------|-------| +| IO-01 | UDP cosim start/stop, status in sidebar | P1 | v1 | +| IO-02 | Telemetry export targets | P2 | v1 | +| IO-03 | FFB UDP port optional | P3 | | + +--- + +## 5. Non-functional requirements + +| ID | Category | Requirement | +|----|----------|-------------| +| NF-01 | Performance | Build-sheet modal interactive < 200 ms perceived (resolve cached) | +| NF-02 | Performance | Play viewport 30+ FPS on integrated GPU (1–4 vehicles) | +| NF-03 | Reliability | No silent catalog resolve failures — show `summary.error` | +| NF-04 | Accessibility | Keyboard: Esc closes modal; focus trap in modal | +| NF-05 | i18n | UI English; Korean README/docs OK; no hard-coded Korean in v2 shell | +| NF-06 | Security | No secrets in browser; Hyundai `.tir` never exposed in GUI | +| NF-07 | Test | `assembly_api`, `multi_vehicle_compare` ctest green after GUI API changes | +| NF-08 | Offline | Lab LAN works without outbound internet | + +--- + +## 6. Visual / design system (summary) + +Full tokens: to be re-authored in `gui/static/tokens.css` + `GUI_V2_DESIGN_SYSTEM.md`. + +| Token area | Requirement | +|------------|-------------| +| Mood | Light, Notion-like; accent `#01A0E9` | +| Modal build-sheet | max-width ~1100px, `.build-sheet` | +| Chips | Preset pills `.v2-chip` / `.v2-chip-on` | +| Typography | System UI 10–15px scale | +| Density | Danawa-style dense rows; resolved stats always visible | + +--- + +## 7. Technical constraints (do not break) + +| Constraint | Detail | +|------------|--------| +| Wheel order | FL=0, FR=1, RL=2, RR=3 | +| Frame | ISO 8855 right-handed | +| Backend | `gui/server.py` Runner + `catalog/assembly.py` | +| No SimSession in browser | Play uses `vdsim_realtime` / cosim relay | +| REST+SSE | No WebSocket requirement for MVP | + +--- + +## 8. Phased delivery (revised — v1 parity explicit) + +| Phase | Scope | Exit criteria | +|-------|-------|---------------| +| **v2.0** | `/v2/app.html` + **build-sheet rewrite** (BS-*, PV-*); v1 `/app.html` **unchanged** for Setup/Play/Compare | Build-sheet done; **§3.1 S/P/A lists still pass on v1** | +| **v2.1** | **Run composition** shell — Map / Vehicle / Options tabs in v2 design system; scenario presets (FS/Auto) | S-01–S-11 re-skinned; no feature loss vs §3.1 | +| **v2.2** | Play viewport + telemetry + minimap in v2 shell; optional global nav Setup·Sim·Analyze | P-01–P-09 parity | +| **v2.3** | Framework (Svelte/React) if justified; G-02 dockable panels | Same wire contract | + +**Regression rule:** Before any v2 phase ships, run manual checklist **§3.1** on the target route. + +**Explicitly out of all v2 phases (until product decision):** CMake GUI, CAD export, in-browser `.tir` editing (G-06), AAA game visuals. + +--- + +## 9. v1 → v2 gap (current codebase) + +| Area | v1 state | v2 target | +|------|----------|-----------| +| `app.js` size | ~4k LOC monolith | Split: `build_viz.js`, shell, routes | +| Build-sheet UX | Double-click install (fixed in stash) | Single-click + hover preview | +| Preview | Three.js car in modal (stash removed) | Level-aware SVG | +| `build_complete` | Was false on L2 (fixed in review commit) | Required slots only | +| `part_compat` | Old slot names (fixed) | `front_chassis` / `body` | +| Design tokens | Partial in stash | Full `tokens.css` | +| powertrain slot | Category without slot | Add slot or remove category | + +--- + +## 10. Locked decisions (2026-06-21) + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| **D1** | Blueprint switch + custom parts | **(B) Confirm dialog** — show which slots reset; default cancel | Review: silent wipe on blueprint change was a bug | +| **D2** | v2 entry | **`/v2/app.html`** (separate shell); v1 `/app.html` unchanged until parity | Safe incremental migration; no breaking lab bookmarks | +| **D3** | Framework | **(A) Vanilla ES modules** for v2.0; Svelte/React re-evaluate at v2.3 | `app.js` already modularizing; wire contract is the boundary | +| **D4** | Session tune in MVP | **(A) v1 tabs on v1 route**; v2.0 only build-sheet | Setup/map stay on v1 until v2.1 | +| **D5** | Compare modal | **(A) Keep v1** — no redesign in v2.0 | Compare already works; polish in v2.1+ | +| **D6** | `powertrain` slot | **(A) Add to `slots.py`** + assembly category | Split-powertrain blueprints editable in build-sheet | + +--- + +## 11. Open decisions (deferred) + +| # | Question | When | +|---|----------|------| +| — | Merge-by-slot blueprint policy (alternative to confirm+replace) | v2.1 if users want non-destructive preset switch | +| — | Svelte vs React | v2.3 framework pass | +| — | WebGPU default renderer | After v2.0 shell stable | + +--- + +## 12. Traceability matrix (feature → verification) + +| Feature | Automated check | +|---------|-----------------| +| Assembly slots L2/L3 | `ctest -R assembly_api` | +| Blueprint resolve | `ctest -R blueprint_roundtrip` | +| Multi-vehicle compare API | `ctest -R multi_vehicle_compare` | +| Catalog index | `ctest -R catalog_api` | +| Schematic API | manual / `GET /api/suspension/schematic` | +| GUI smoke | `tests/scripts/test_compare.py` (headless) | + +--- + +## 13. Related artifacts + +| Doc | Role | +|-----|------| +| `GUI_V2_DESIGN_SYSTEM.md` | Visual spec (to recreate on restart) | +| `GUI_V2_SPEC.md` | Prior round decisions (archive after merge into this doc) | +| `docs/HANDOFF.md` | Session handoff | +| Stash `wip: main GUI v2 + build_viz` | Prototype code — **do not merge blindly**; cherry-pick per requirement ID | diff --git a/docs/design/GUI_V3_ADR.md b/docs/design/GUI_V3_ADR.md new file mode 100644 index 00000000..f0044021 --- /dev/null +++ b/docs/design/GUI_V3_ADR.md @@ -0,0 +1,372 @@ +# GUI v3 — Architecture Decision Records + +Status: **accepted** (2026-06-21) +Parent: [`GUI_V3_REQUIREMENTS.md`](GUI_V3_REQUIREMENTS.md) +Resolves open decisions **O1, O3, O4** from v3 requirements §11. + +--- + +## ADR-001 — Application framework: **Svelte 5 + Vite SPA** + +### Context + +- v1 is a **4235 LOC** vanilla `app.js` monolith on stdlib `http.server`. +- v3 needs four modes, a component design system, typed API client, and maintainable + module boundaries (NF-09: no file > 800 LOC). +- Backend stays Python (`gui/server.py`); **no SSR**, no Node server in production. +- Lab constraints: offline-first, integrated GPU, SSE @ ~60 Hz telemetry. + +### Options considered + +| Option | Pros | Cons | +|--------|------|------| +| **A. Svelte 5 + Vite SPA** | Fine-grained updates (SSE-friendly); low boilerplate for dense rows/forms; small bundles; single-file components | Smaller hiring pool vs React; Three.js lifecycle needs discipline | +| **B. React 19 + Vite SPA** | Largest ecosystem (docking, charts); most agent/human familiarity | More boilerplate; larger runtime; easy to over-abstract | +| **C. SvelteKit** | File routing, adapters | SSR/adapters unused; couples to Kit conventions for a static lab SPA | +| **D. Vue 3 + Vite** | Good DX | Weaker fit for planned docking/chart libs; team has no Vue history | +| **E. Stay vanilla ES modules** | Zero build step | Already failed at v1 scale; rejected in v3 charter | + +### Decision + +**A — Svelte 5 + Vite SPA** (not SvelteKit). + +### Rationale + +1. **No SSR benefit** — app is a local authoring client over REST/SSE; static `dist/` is enough. +2. **SSE / Run mode** — compiler-driven fine-grained DOM updates avoid re-rendering large React trees + on every 16 ms state tick (telemetry drawer can subscribe narrowly). +3. **Build + Compose density** — Danawa slot rows, inspector fields, and status chips are + faster to ship in Svelte with less state-plumbing than React hooks/context. +4. **Bundle size** — lab LAN + offline vendor fonts; smaller initial JS helps cold load. +5. **Monolith split** — `.svelte` per panel maps cleanly to `modes/{scenario,run,build,analyze}/`. + +### Consequences + +- Add `gui/v3/package.json`, `vite.config.ts`, `svelte.config.js`. +- **Dev prerequisite:** Node **≥ 20** (system has v24 — OK). Python server unchanged. +- `gui/v3/dist/` is **build output** — not committed; documented in README + CMake optional target later. +- Port v1 Three.js scene logic to `gui/v3/src/lib/viewport/` as imperative TS module + (no `@react-three/fiber` — keep v1 approach, less magic). +- Agents/contributors need Svelte 5 runes/docs; link in `gui/v3/README.md`. + +### Revisit if + +- Team standardizes on React for other products and won't maintain Svelte. +- A required P0 library is React-only **and** has no Svelte port (none identified today). + +--- + +## ADR-002 — Language: **TypeScript (strict)** + +### Decision + +All v3 app code in **TypeScript** with `strict: true`. + +### Rationale + +Wire contract has many endpoints (`/api/setup`, `/api/catalog/assembly`, `/api/compare`, …). +Typed `api/*.ts` modules catch field drift at compile time; aligns with NF-07 API stability. + +### Consequences + +- Hand-maintained types in `gui/v3/src/lib/api/types.ts` initially. +- Optional later: generate from OpenAPI if `gui/api` documents JSON shapes. + +--- + +## ADR-003 — Routing: **hash mode** (`/v3/#/scenario`) + +### Context + +`gui/api/routes.py` serves exact paths; no generic SPA fallback today. + +### Options + +| Option | Server change | Bookmarkable | +|--------|---------------|--------------| +| Hash `#/build` | Serve single `index.html` at `/v3/` only | Yes (full URL) | +| History `/v3/build` | Catch-all → `index.html` for every `/v3/*` | Cleaner URLs | + +### Decision + +**Hash routing** for v3.0-alpha through v3.0-rc. Default hash: **`#/scenario`**. + +### Rationale + +- **Minimal server diff** — one new route `/v3/` + static asset mount for `dist/assets/`. +- Avoids accidental shadowing of future `/api/*` paths. +- Upgrade to history API in v3.1 is a client-only change + small routes.py fallback. + +### Implementation + +- Router: lightweight (`svelte-spa-router` or ~50 LOC hash store). +- Modes: `#/scenario` | `#/run` | `#/build` | `#/analyze` — **default `#/scenario`**. + +--- + +## ADR-004 — Build & deploy layout + +### Decision + +``` +gui/v3/ + package.json + vite.config.ts # base: '/v3/', outDir: 'dist' + src/ + main.ts + App.svelte # shell + nav + modes/ ... + lib/api/ ... + lib/viewport/ ... + components/ ... + dist/ # gitignored — npm run build +``` + +Vite `base: '/v3/'` so asset URLs work when served under `/v3/`. + +### Server integration (`gui/api/routes.py` — planned) + +| Route | Serves | +|-------|--------| +| `GET /v3`, `GET /v3/` | `gui/v3/dist/index.html` | +| `GET /v3/assets/*` | `gui/v3/dist/assets/*` | +| `GET /app.html` | v1 (until v3.0 cutover) | + +v1 `/static/*` and `/vendor/*` unchanged. + +### Build commands + +```bash +cd gui/v3 && npm ci && npm run build # production +cd gui/v3 && npm run dev # Vite dev server; proxy /api → :8095 +``` + +### Consequences + +- CI (future): add Node job `npm run check && npm run build` — does not block ctest today. +- README: v3 dev is optional; default users still use v1 until cutover. + +--- + +## ADR-005 — Three.js: **keep vendored copy** + +### Decision + +Import Three.js from existing `gui/vendor/three/` via Vite alias — **no CDN**, no duplicate npm three unless vendor is removed later. + +```ts +// vite.config.ts (sketch) +resolve: { + alias: { + three: path.resolve(__dirname, '../vendor/three/three.module.js'), + }, +}, +``` + +### Rationale + +P5 offline-first; v1 already ships vendor tree; pin version when upgrading. + +### Consequences + +- WebGPU renderer path same as v1 (`three/addons/` from vendor). +- NF-02 perf validation stays comparable to v1 baseline. + +--- + +## ADR-006 — Layout / docking: **CSS split panes first** + +### Context + +Design review asked for dockable panels (G-02). Libraries like `flexlayout-react` are React-only. + +### Decision + +v3.0-alpha/beta: **CSS grid + `resize` handles** per mode default layout (requirements §4.3). +No docking library until v3.1 **if** users need persisted arbitrary layouts. + +### Rationale + +- Ships faster; satisfies “resizable panels” for default Compose/Build splits. +- Keeps framework choice from being forced by a React-only dock lib. +- SH-06 (localStorage layout persist) stores pane widths only, not arbitrary dock graphs. + +### Revisit if + +- FS/Auto users request IDE-style free docking → evaluate `golden-layout` (framework-agnostic) + or migrate layout shell to React (would trigger ADR-001 revisit). + +--- + +## ADR-007 — Design system workflow: **in-repo tokens + Storybook** + +### Context + +O2: Figma vs Storybook. HANDOFF: “design pass before more hand-coded CSS.” + +### Decision + +1. **`gui/v3/src/tokens.css`** — source of truth for colors, spacing, typography (DS-01). +2. **Storybook 8 + Svelte** in `gui/v3/.storybook/` for core components (DS-02). +3. **Figma optional** — link in README if maintainer maintains one; not a merge blocker. + +### Rationale + +- OSS repo: Storybook lives with code, reviewable in PRs, no external tool lock-in. +- Components (Chip, DenseRow, InspectorField) get visual regression baseline for agents. + +### Consequences + +- `npm run storybook` for local design review (DS-05 gate). +- No Figma-to-code automation in v3.0. + +--- + +## ADR-008 — v1 deprecation & cutover + +### Decision + +| Milestone | Policy | +|-----------|--------| +| v3.0-alpha … rc | v1 `gui/app.html` = **production**; `/v3/` = preview | +| v3.0 | `/app.html` redirects to `/v3/`; move v1 to `gui/legacy/` (read-only, one release) | +| After v3.0 +1 | Delete `gui/legacy/` if no rollback request | + +### Rationale + +P6 parity-before-cutover; lab bookmarks on `/app.html` get redirect, not silent break. + +--- + +## ADR-009 — State management + +### Decision + +| Scope | Approach | +|-------|----------| +| Server draft (fleet, scene, path) | **Server truth** — `GET/POST /api/setup`; UI shows sync errors | +| SSE stream (Run) | Dedicated `viewport` module + narrow Svelte stores for telemetry | +| Build assembly | Fetch on action; debounced preview query param | +| Cross-mode | Shell store: `scenarioName`, `liveVid`, `connStatus` | + +No Redux/Pinia. Avoid global client cache of physics params beyond session. + +### Rationale + +R6 — physics resolves on server; duplicating resolve client-side caused v1 bugs. + +--- + +## ADR-010 — API client pattern + +### Decision + +Thin module per domain: + +``` +gui/v3/src/lib/api/ + client.ts # fetchJson, error envelope + setup.ts + catalog.ts + control.ts + compare.ts + stream.ts # EventSource wrapper + types.ts +``` + +Generated from `gui/api/routes.py` route list — manual types until OpenAPI exists. + +### Consequences + +- Any new `/api/*` route: update `types.ts` + ctest; NF-07 unchanged. + +--- + +--- + +## ADR-011 — Product priority: **Scenario studio first, Run viz second** + +Status: **accepted** (2026-06-21) + +### Context + +User direction: the GUI’s **main job** is **scenario generation** (`configs/scenes/`). +**Second** is **realtime visualization** while the plant runs — not the other way around. +v1 felt “3D viewer with setup bar attached”; v3 must invert that emphasis. + +### Decision + +| Priority | Mode | Route | v3 phase | +|----------|------|-------|----------| +| **1** | **Scenario** (시나리오 생성기) | `#/scenario` (default) | v3.0-alpha | +| **2** | **Run** (realtime 시각화) | `#/run` (via ▶ or tab) | v3.0-beta | +| 3 | Build (agent vehicle parts) | `#/build` or drawer from Scenario | v3.0-rc | +| 4 | Analyze | `#/analyze` | v3.0-rc | + +### Rationale + +- Matches VDSim runtime split: [1] gui = scenario editor + run controller ([`RUNTIME_ARCH.md`](RUNTIME_ARCH.md)). +- Scenario authoring is the differentiated OSS surface; Run reuses existing SSE + Three.js port. +- Build stays necessary (fleet `blueprint` + `parts`) but is **in service of** scenario agents. + +### Consequences + +- Shell nav highlights **Scenario** and **Run**; Build/Analyze de-emphasized. +- **▶ Play** syncs draft then **auto-navigates to Run** (SH-03). +- Storybook / component priority: map canvas, fleet inspector, path editor before schematic/build-sheet. +- Implementation order in ADR “Next steps” follows Scenario → Run → Build. + +--- + +## Summary table + +| ADR | Decision | +|-----|----------| +| 001 | **Svelte 5 + Vite SPA** | +| 002 | TypeScript strict | +| 003 | Hash router `/v3/#/scenario` (default) | +| 004 | Build to `gui/v3/dist/`; server serves `/v3/` | +| 005 | Three.js from `gui/vendor/` | +| 006 | CSS split panes; no dock lib in v3.0 | +| 007 | tokens.css + Storybook (Figma optional) | +| 008 | v1 until v3.0 parity → redirect | +| 009 | Server-truth draft; minimal client state | +| 010 | Typed `lib/api/*` modules | +| **011** | **Scenario-first, Run-second product priority** | + +--- + +## Rejected: React + Vite (record) + +React remains a **credible alternate**. Not chosen because: + +- v3 UI is form-dense + SSE-heavy, not a large component tree with shared global state. +- Planned v3.0 does not need React-only libs (docking deferred). +- Bundle and boilerplate cost matters for a sidecar lab UI. + +If ADR-001 is reopened, use **React 19 + Vite + TypeScript** with the same ADR-003–010 +(hash routing, vendor three, server layout, deprecation policy). + +--- + +## Next implementation steps + +1. Scaffold `gui/v3/` (`npm create vite@latest` svelte-ts template, adapt). +2. Add `gui/v3/.gitignore` (`node_modules`, `dist`). +3. Patch `gui/api/routes.py` — `/v3/` + `/v3/assets/*` (small PR). +4. Shell: default `#/scenario` + **Run** tab + `api/client.ts` health check. +5. Storybook + `tokens.css` — **Scenario** components first (map, fleet row, path wp). +6. **Scenario mode** CO-00–CO-08, CO-16 (v3.0-alpha). +7. **Run mode** RN-00–RN-07 + viewport port from v1 (v3.0-beta). +8. Build drawer + Analyze (v3.0-rc). + +--- + +## Related + +| Doc | Role | +|-----|------| +| [`GUI_V3_REQUIREMENTS.md`](GUI_V3_REQUIREMENTS.md) | Functional requirements | +| [`GUI_V2_REQUIREMENTS.md`](GUI_V2_REQUIREMENTS.md) | v1 parity §3.1 | +| [`../gui_architecture.md`](../gui_architecture.md) | Wire contract | +| [`../HANDOFF.md`](../HANDOFF.md) | Session handoff | diff --git a/docs/design/GUI_V3_REQUIREMENTS.md b/docs/design/GUI_V3_REQUIREMENTS.md new file mode 100644 index 00000000..2981c7cf --- /dev/null +++ b/docs/design/GUI_V3_REQUIREMENTS.md @@ -0,0 +1,452 @@ +# GUI v3 — feedback synthesis & requirements + +Status: **planning baseline** (2026-06-21) +Supersedes **`GUI_V2_REQUIREMENTS.md` for product direction** — v2 incremental route is **cancelled**; v1 `/app.html` remains the production surface until v3 parity. +Wire contract: [`../gui_architecture.md`](../gui_architecture.md) (REST + SSE) +Catalog: [`PARTS_CATALOG.md`](PARTS_CATALOG.md) · Scenario: [`../CONFIG_GUIDE.md`](../CONFIG_GUIDE.md) + +--- + +## 0. Charter — why v3, not v2 + +| Approach | What we tried | Outcome | +|----------|---------------|---------| +| **v0.2 → v1** | Full-screen `app.html`, sidebar + modal workshops + setup bar | **Feature-complete prototype** — all three composition layers shipped, but IA debt | +| **v2** | `/v2/app.html`, rewrite build-sheet only; rest stays on v1 | **Requirements felt incomplete** — map/scene/fleet/compare treated as “carry-over”; two UIs, no unified shell | +| **v3** | **Greenfield shell** — modes, dockable layout, design system first; **one** app route when ready | Target: credible OSS engineering UI, not a patched monolith | + +**v3 rule:** Do not ship another partial skin. Ship a **coherent workspace** with explicit parity against [`GUI_V2_REQUIREMENTS.md` §3.1](GUI_V2_REQUIREMENTS.md) (v1 inventory). + +### Product priority (2026-06-21 — locked) + +| Rank | GUI role | v3 mode | Investment | +|------|----------|---------|------------| +| **1 — Main** | **시나리오 생성기** — map, road, path, fleet/agents, save/load `configs/scenes/` | **Scenario** | ~60% UX / v3.0-alpha | +| **2 — Second** | **Realtime 시각화** — Play 중 Three.js + telemetry (plant state reconstruction) | **Run** | ~30% / v3.0-beta | +| 3 — Supporting | 차량 부품 조립 (agent `blueprint` + parts) | **Build** (panel in Scenario + deep-edit route) | ~10% / v3.0-rc | +| 4 — Validation | ISO compare, metrics | **Analyze** | polish / v3.1 | + +The GUI is **not** a game client or a CAD tool — it is a **scenario authoring studio** with an attached **runtime viewer**. + +--- + +## 1. Feedback synthesis (v1 development → v2 planning) + +### 1.1 External / design review (HANDOFF 2026-06) + +| Score / verdict | Finding | +|-----------------|---------| +| **~4/10 “prototype”** | Looks bolted-on; not a product-grade authoring tool | +| Layout | Need **dockable / resizable** panels; stop stacking unrelated concerns in fixed columns | +| Navigation | Need **global mode nav** — user mental model: **Compose → Build → Run → Analyze** (HANDOFF: Setup · Sim · Analyze) | +| Modals | **Large vehicle modal** hides context; build-sheet + session tune + parts library trapped behind one overlay | +| Design | Need **design system** (tokens, components, density) before more hand-coded CSS | +| Stack | `stdlib http.server` + **4.2k LOC `app.js`** monolith is a maintenance ceiling — framework decision **before** more UI code | + +### 1.2 What worked (keep in v3) + +| Area | Evidence | v3 carry-forward | +|------|----------|------------------| +| **Three composition layers** | Vehicle build / scene compose / compare are distinct VDSim objects | Becomes **top-level modes**, not sidebar + setup bar + modals | +| **Danawa build-sheet** | Slot rows, categories, resolved stats, preset chips | **Build mode** primary surface (not a modal) | +| **Schematic preview** | Level-aware SVG (L1–L5), Adams linkage from API — stash removed 3D car in modal | **Build mode** center panel; no runtime mesh in authoring | +| **Compare dashboard** | `/api/compare`, yaw-rate overlay, Δ% table | **Analyze mode** full page | +| **Wire contract** | REST draft + SSE stream; GUI does not step physics | **Frozen** — v3 is frontend-only replacement | +| **Catalog assembly API** | `assembly.py`, preview Δ stats, `build_complete` | Same backend; v3 client uses typed API wrapper | +| **Setup bar concepts** | Map / fleet / path / save | **Scenario mode** inspector + map | +| **Play viewport** | Three.js from SSE | **Run mode** (secondary product) | +| **mkdocs help** | `[?]` → theory/docs | All non-obvious labels | + +### 1.3 What failed or hurt (fix in v3) + +| Pain | Symptom | v3 response | +|------|---------|-------------| +| **Modal-centric IA** | Vehicle edit = full-screen modal; compare = another modal; context loss | **Mode pages** + optional **drawers**; no full-screen modal for primary workflows | +| **Duplicate entry points** | Blueprint/level in fleet tree, assembly modal, setup vehicle tab | **Single source of truth** per field; cross-links only | +| **Split attention** | Sidebar sim + bottom setup bar + center 3D + right telemetry | **Mode-specific layout** — only show panels relevant to current job | +| **Incremental v2 trap** | Build-sheet on `/v2`, map/spawn on `/app.html` | **One route** (`/v3/` or `/app.html` swap) at parity gate | +| **UX bugs (found in review)** | Double-click to install; silent blueprint parts wipe; `build_complete` false on L2 | Fixed in core/catalog; v3 must **confirm destructive edits** (D1) | +| **Monolith JS** | `app.js` 4235 LOC — scene, assembly, setup, compare, path edit intertwined | **Framework + modules** by mode and domain | +| **Requirements doc drift** | v2 doc over-indexed build-sheet; map/fleet under-documented until late | v3 doc inherits **full §3.1 parity IDs** (see §8) | +| **Visual polish without system** | One-off CSS, inconsistent spacing/typography | **Design tokens + component library** before feature sprint | + +### 1.4 User / session feedback (condensed) + +| Request | v1 state | v3 requirement | +|---------|----------|----------------| +| 차량 구성 (parts, presets, stats) | Assembly modal | **Build mode** — full width | +| 지도·도로·terrain 구성 | Setup → Map | **Scenario** mode | +| 경로·waypoint 편집 | Setup table + 3D edit | **Scenario** map tools | +| 플릿 spawn | Setup Vehicle + drag | **Scenario** fleet inspector | +| 시나리오 save/load | Setup header | **Scenario** shell (CO-16) | +| Realtime 3D 시각화 | Sidebar + viewport | **Run** mode (▶ from Scenario) | +| ISO 비교 | Compare modal | **Analyze mode** | +| Session tune (타이어·서스) | Modal tabs | **Build mode** sub-inspector (not separate modal) | +| Single-click part install | Fixed in stash | **Build mode** default | +| Hover Δ stats preview | Stash / API | **Build mode** | +| Teaching credibility | Schematic not game mesh | **NF-10** — engineering aesthetic | + +### 1.5 Deferred from v0.5.2 / HANDOFF (still in v3 backlog) + +| ID | Item | v3 phase | +|----|------|----------| +| G-01 | Multi-fleet 3D render polish (#158) | Run mode P1 | +| G-04 | Terrain Play — chase on mesh, spawn on surface | Run mode P1 | +| G-05 | Stunt **authoring** (not render-only) | Compose P2 | +| G-06 | GUI `.tir` import | Build P2 (metadata only; no coefficient editor) | +| G-07 | Belt transient force arrow viz | Run P2 | +| G-08 | `powertrain` catalog slot | Backend + Build P0 | +| G-09 | Blueprint switch confirm | Build P0 | + +--- + +## 2. Product positioning + +| Item | Requirement | +|------|-------------| +| **Primary product** | **Scenario generator** — author `scene.yaml`: shared map/road/comms + per-agent fleet, path, spawn | +| **Secondary product** | **Realtime visualization** — while plant runs, reconstruct pose/tires/forces from SSE (not pixel stream) | +| Not | CAD, tire coefficient workshop, AAA game UI, primary 3D editor | +| Differentiator | YAML-native scenarios + compact physics state → local Three.js | +| Audience | Maintainer, FS/Auto teams, ADAS students composing experiments | +| Success (primary) | New user loads template → edits map/path/fleet → saves scenario → **≤3 min** without reading source | +| Success (secondary) | ▶ Play → Run mode shows credible motion + telemetry within **2 s** of sync | + +**VDSim object the GUI owns:** `configs/scenes/*.yaml` (and derived `runs/live/` on Play). +Vehicle physics manifests (`blueprints/`, `parts/`) are **edited in service of** scenario agents, not as a standalone app goal. + +--- + +## 3. v3 principles + +| # | Principle | +|---|-----------| +| P0 | **Scenario-first** — default mode, default route, most polish budget | +| P1 | **Run-second** — visualization serves the scenario being played, not a separate “game” | +| P2 | **One workspace** — Scenario · Run · (Build · Analyze); one design system | +| P3 | **Wire contract frozen** — REST/SSE schema is the migration boundary | +| P4 | **Authoring ≠ runtime** — Scenario edits draft; Play syncs → `runs/live/` → Run visualizes | +| P5 | **No physics in browser** — server resolves catalog; GUI edits manifests + session overrides | +| P6 | **Offline-first** — `gui/vendor/` fonts/icons; lab LAN without CDN | +| P7 | **Parity before cutover** — v1 stays at `/app.html` until §8 checklist passes on `/v3/` | +| P8 | **Design system before features** — Storybook for scenario inspector components first | +| P9 | **Not game UI** — Notion/Linear-like light OSS; `#01A0E9` accent; dense engineering rows | + +--- + +## 4. Information architecture (v3) + +### 4.1 Global shell — Scenario-centric + +**Default route:** `/v3/#/scenario` (app opens in scenario studio). + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ VDSim [ Scenario ★ ] [ Run ] Build · Analyze (secondary) scenario ▾ │ +├─────────────────────────────────────────────────────────────────────────┤ +│ SCENARIO STUDIO (default) — map · path · fleet · road · save/load │ +│ or RUN VIEWPORT — Three.js + transport (entered via ▶ or Run tab) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ draft synced · [Sync] [▶ Play → Run] · last run · warnings · [?] │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +| Mode | Priority | User job | Primary viewport | +|------|----------|----------|------------------| +| **Scenario** | **1 — Main** | 시나리오 생성·편집 | **Map canvas** — road, terrain, path, spawn, fleet agents | +| **Run** | **2 — Second** | Realtime 시각화 | **Three.js** — chase/top/orbit, force vectors, telemetry drawer | +| **Build** | 3 — Supporting | Agent 차량 부품 조립 | Schematic + slot list (opens from Scenario fleet row or `#/build`) | +| **Analyze** | 4 — Validation | ISO compare | Charts + Δ% table | + +**Cross-mode rules** + +- Scenario name + draft sync **always visible**; **▶ Play** syncs draft then switches to **Run**. +- **Build** is scoped to selected fleet agent (`vid`); entry from Scenario fleet inspector, not the default landing. +- Run mode is **read-mostly** for scene params — “back to Scenario” for edits. +- No modal larger than confirm / save-as / import. + +### 4.2 Scenario studio layout (main surface) + +``` +┌──────────────── map / path editor (70%) ────────┬─ inspector (30%) ─┐ +│ · road μ / grade / bank preview │ Scenario meta │ +│ · fleet spawn markers (drag) │ Road │ +│ · path waypoints (●) │ Map / terrain files │ +│ · optional light 3D ground (not full Run viz) │ Fleet agents[] │ +│ │ → blueprint link │ +│ │ → [Edit parts] │ +│ │ Path preset / wps │ +│ │ Comms / cosim (adv) │ +└────────────────────────────────────────────────────┴───────────────────────┘ +``` + +Fleet agent row **Edit parts** opens Build as **split pane or drawer** without leaving Scenario context when possible (deep edit → `#/build?vid=`). + +### 4.3 Run layout (visualization surface) + +``` +┌──────────────────── Three.js viewport (100%) ────────────────────────────┐ +│ transport: ⏹ ⟲ · time scale · cam orbit/chase/top · manual (optional) │ +│ overlay: spawn hint off · wheel forces · selected vehicle telemetry → │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +Minimap trail lives in Run (or PiP); Scenario map shows **static** fleet/path for authoring. + +### 4.4 v1 → v3 surface mapping + +| v1 location | v3 | +|-------------|-----| +| Setup bar (Map/Vehicle/Options) | **Scenario** mode | +| Sidebar Play + center 3D | **Run** mode (+ ▶ from shell) | +| Modal → Assembly | **Build** (from Scenario agent) | +| Compare modal | **Analyze** | +| Sidebar fleet tree | Scenario **Fleet agents** inspector | + +### 4.5 Default layouts + +| Mode | Layout | +|------|--------| +| **Scenario** | `map (70%)` \| `inspector (30%)` — resizable | +| **Run** | `viewport (100%)` — telemetry slide-over | +| Build | `slots (25%)` \| `schematic (45%)` \| `picker + stats (30%)` | +| Analyze | `config top` \| `chart (60%)` \| `table (40%)` | + +Docking: CSS split panes (ADR-006). + +--- + +## 5. Functional requirements + +IDs extend v2 where noted. **P0** = v3 launch blocker. + +### 5.1 Shell & navigation + +| ID | Requirement | P | Acceptance | +|----|-------------|---|------------| +| SH-01 | **Scenario** default route `#/scenario`; **Run** `#/run`; Build/Analyze secondary | P0 | Opens on Scenario | +| SH-02 | Scenario selector + load/save/export/import in shell | P0 | Replaces setup header | +| SH-03 | Draft sync + **▶ Play** → sync → auto-switch to **Run** | P0 | Same as S-02 | +| SH-04 | Connection + kinematics warnings in status strip | P0 | v1 parity | +| SH-05 | mkdocs `[?]` on labels | P0 | HP-01 | +| SH-06 | Panel resize persist (localStorage) | P1 | Per mode | +| SH-07 | Keyboard: Esc closes drawer; `?` opens help | P1 | NF-04 | + +### 5.2 Scenario mode (시나리오 생성기 — **P0 main**) + +Was “Compose”. IDs unchanged (`CO-*`). + +| ID | Requirement | P | v1 ref | +|----|-------------|---|--------| +| CO-00 | **New scenario** template gallery (empty, skidpad, straight, figure-8) | P0 | new | +| CO-01 | Road μ, grade°, bank°, v_target | P0 | S-03 | +| CO-02 | OpenDRIVE `.xodr` load | P1 | S-04 | +| CO-03 | Terrain `.obj` load + clear | P1 | S-05 | +| CO-04 | Fleet spawn table (x0,y0,yaw0,vx0…) | P0 | S-06 | +| CO-05 | Map drag spawn + multi-vehicle add | P0 | S-07, S-16 | +| CO-06 | Path preset + waypoint table | P0 | S-08, S-09 | +| CO-07 | Path edit on map (drag ●, add, Del) | P0 | S-10 | +| CO-08 | Static fleet + path on Scenario map; trail overlay in **Run** only | P0 | S-11 | +| CO-09 | Cosim attach host/ports | P1 | S-12 | +| CO-10 | IO / telemetry targets | P2 | S-13 | +| CO-11 | C++ module workshop | P2 | S-14 | +| CO-12 | Infra sensors tree | P2 | S-15 | +| CO-13 | Stunt viz when scene defines `stunt:` | P2 | S-17 | +| CO-14 | Named scenario presets in repo (`configs/scenes/`) surfaced as chips | P1 | new | +| CO-15 | Fleet agent row → **Edit parts** (Build drawer or `#/build?vid=`) | P0 | cross-mode | +| CO-16 | **Save** writes `configs/scenes/.yaml`; load/export/import in shell | P0 | S-01 | + +### 5.3 Build mode (vehicle — **supporting**, entry from Scenario) + +Deep-edit route for catalog assembly. Not the default landing. + +| ID | Requirement | P | v1 ref | +|----|-------------|---|--------| +| BU-01 | Preset blueprint chips → slot list | P0 | BS-02 | +| BU-02 | Categories: Body&chassis / Powertrain / Grip / Control | P0 | BS-03 | +| BU-03 | Single-click row → part cards; hover Δ stats | P0 | BS-04–06 | +| BU-04 | Resolved stats panel (mass, WB, CG, μ, drive) | P0 | BS-05 | +| BU-05 | `build_complete` on required slots only | P0 | BS-07 | +| BU-06 | Level L1–L5 updates slot set | P0 | BS-08 | +| BU-07 | Compat badges on cards | P1 | BS-09 | +| BU-08 | Blueprint change **confirm** (slots reset list) | P0 | BS-11, G-09 | +| BU-09 | Export / register blueprint | P1 | V-10 | +| BU-10 | Schematic: L1–L2 textbook, L3 7-DOF, L4–L5 Adams SVG | P0 | PV-01–04 | +| BU-11 | No Three.js vehicle mesh in Build | P0 | PV-01 | +| BU-12 | Session tune tabs in inspector (chassis, tire, susp, …) | P1 | V-07–V-09 | +| BU-13 | K&C plots L3/L4 | P2 | V-08 | +| BU-14 | Parts library drawer (browse/import) | P1 | V-06, PT-* | +| BU-15 | `powertrain` slot when catalog ships | P0 | G-08 | + +### 5.4 Run mode (realtime 시각화 — **P0 second**) + +| ID | Requirement | P | v1 ref | +|----|-------------|---|--------| +| RN-00 | Enter via **▶ Play** (sync → Run) or **Run** tab when sim active | P0 | SH-03 | +| RN-01 | Play / Stop / Reset | P0 | P-01 | +| RN-02 | Autopilot vs manual + manbar / keyboard | P0 | P-02 | +| RN-03 | Realtime time scale | P0 | P-03 | +| RN-04 | Cam orbit / chase / top | P0 | P-04 | +| RN-05 | SSE ~60 Hz → fleet meshes | P0 | P-05 | +| RN-06 | Wheel force triad (common origin) | P1 | P-06 | +| RN-07 | Telemetry drawer | P0 | P-07 | +| RN-08 | Multi-fleet 3D (#158) | P1 | G-01 | +| RN-09 | Terrain-aware chase / spawn on mesh | P1 | G-04 | +| RN-10 | Sim cfg load/save | P1 | S-18 | +| RN-11 | WebGPU optional, WebGL2 fallback | P2 | PL-05 | + +### 5.5 Analyze mode (was Compare modal) + +| ID | Requirement | P | v1 ref | +|----|-------------|---|--------| +| AN-01 | Multi blueprint select from fleet | P0 | A-01–A-02 | +| AN-02 | ISO maneuver picker | P0 | A-03 | +| AN-03 | Yaw-rate overlay chart (SVG or canvas) | P0 | A-04 | +| AN-04 | Δ% table vs baseline | P0 | A-05 | +| AN-05 | Export compare CSV | P1 | headless parity | +| AN-06 | Separate from Build (no embedded compare) | P0 | CP-03 | + +--- + +## 6. Design system requirements + +| ID | Requirement | +|----|-------------| +| DS-01 | Token file: color, spacing, radius, typography (`gui/v3/tokens.css` or CSS vars from build) | +| DS-02 | Components: Button, Chip, DenseRow, Panel, InspectorField, StatusBadge, ChartFrame | +| DS-03 | Light theme default; accent `#01A0E9`; system font stack | +| DS-04 | Danawa-density slot rows; resolved stats always visible in Build | +| DS-05 | Design review gate: external or maintainer sign-off **before** Run mode polish | +| DS-06 | Optional: Figma file linked from repo; or Storybook in `gui/v3/storybook/` | + +**Reference mood:** Notion / Linear / OSS engineering — not racing game HUD. + +--- + +## 7. Technical architecture + +### 7.1 Stack (decisions required in week 0) + +| # | Decision | Options | v3 recommendation | +|---|----------|---------|-------------------| +| T1 | Framework | SvelteKit, React+Vite, Vue | **Svelte 5 + Vite SPA** — see [`GUI_V3_ADR.md`](GUI_V3_ADR.md) ADR-001 | +| T2 | Entry route | `/v3/`, `/app.html` swap | **`/v3/index.html`** until parity → redirect `/app.html` | +| T3 | 3D | Three.js ES module | Keep; extract `packages/viewport/` or `gui/v3/lib/viewport/` | +| T4 | API client | hand `fetch` | Thin typed wrapper from route list in `gui/api/` | +| T5 | State | mode-local stores | Compose draft = server truth; optimistic UI with sync errors surfaced | +| T6 | Build | Vite | Bundle to `gui/v3/dist/` served by `server.py` | +| T7 | Backend | `gui/server.py` | **No change** for v3.0 | + +### 7.2 Module boundaries (target) + +``` +gui/v3/ + app/ # shell, router, mode layouts + modes/ + scenario/ # P0 — scenario studio (was compose/) + run/ # P1 — realtime viz + build/ # supporting + analyze/ + lib/ + api/ # fetch wrappers + viewport/ # Three.js (Run) + schematic/ # SVG build preview + charts/ # Analyze + components/ # design system + tokens.css +``` + +v1 `gui/static/app.js` **not extended** — maintenance mode only until v3 cutover. + +### 7.3 Wire contract + +| Rule | Detail | +|------|--------| +| Unchanged | `POST /api/setup`, `/api/control`, `/api/stream`, `/api/catalog/*`, `/api/compare`, … | +| Optional v3 additions | `GET /api/schema/*` expansion for form codegen (future); not blocking v3.0 | +| Wheel order | FL=0, FR=1, RL=2, RR=3 | +| Frame | ISO 8855 RH | + +--- + +## 8. Parity checklist (v3 launch gate) + +All items from [`GUI_V2_REQUIREMENTS.md` §3.1](GUI_V2_REQUIREMENTS.md) must pass on `/v3/`. Summary: + +| Group | Count | v3 mode | +|-------|-------|---------| +| V-* vehicle build | 11 | Build (from Scenario) | +| S-* scene compose | 18 | **Scenario** | +| P-* play runtime | 9 | **Run** | +| A-* analyze | 5 | Analyze | +| G-* gaps | 9 | per table §1.5 (P0 items block launch) | + +**Automated:** `ctest -R 'assembly|catalog|multi_vehicle_compare|gui_v3_api_smoke|gui_v3_e2e'` + `python3 tools/gui_v3_eval_bundle.py` (npm + Playwright + parity audit). + +--- + +## 9. Non-functional requirements + +| ID | Category | Requirement | +|----|----------|-------------| +| NF-01 | Performance | Build interactions < 200 ms perceived (cached resolve) | +| NF-02 | Performance | Run viewport 30+ FPS integrated GPU, 1–4 vehicles | +| NF-03 | Reliability | Show `summary.error` on catalog resolve failure | +| NF-04 | Accessibility | Focus trap in drawers; keyboard transport in Run | +| NF-05 | i18n | UI English | +| NF-06 | Security | No Hyundai `.tir` secrets in browser | +| NF-07 | Test | API ctests green after any `gui/api` change | +| NF-08 | Offline | Lab LAN without internet | +| NF-09 | Maintainability | No single file > 800 LOC (enforced in review) | +| NF-10 | Aesthetic | Engineering schematic > game mesh for authoring | + +--- + +## 10. Phased delivery (scenario-first) + +| Phase | Deliverable | Exit | +|-------|-------------|------| +| **v3.0-alpha** | Shell + **Scenario studio** CO-00–CO-08, CO-16 + templates | Author/save scenario without v1 | +| **v3.0-beta** | **Run visualization** RN-00–RN-07 (▶ → sync → Run) | Realtime viz credible | +| **v3.0-rc** | **Build** drawer/route BU-01–BU-11 + **Analyze** AN-01–AN-04 | §8 parity manual pass | +| **v3.0** | Redirect `/app.html` → `/v3/#/scenario`; v1 → `gui/legacy/` | Cutover | +| **v3.1** | G-01/G-04 Run polish, CO-09–CO-13, dock persist | | +| **v3.2** | G-05 stunt authoring, G-06, time-series Analyze | | + +**Investment order:** Scenario **→** Run **→** Build **→** Analyze. + +**Explicitly out of v3.0:** CMake GUI, CAD export, in-browser coefficient editing, AAA visuals. + +--- + +## 11. Open decisions (week 0) + +| # | Question | Owner | Blocker for | +|---|----------|-------|-------------| +| ~~O1~~ | ~~SvelteKit vs React+Vite~~ | — | **Closed → ADR-001: Svelte 5 + Vite SPA** | +| O2 | Figma emphasis vs Storybook-only | Maintainer | DS-05 (ADR-007: Storybook primary) | +| ~~O3~~ | Docking library vs fixed split panes | — | **Closed → ADR-006: CSS splits v3.0** | +| ~~O4~~ | `/v3/` URL vs hash routing | — | **Closed → ADR-003: hash** | +| O5 | Merge-by-slot blueprint (non-destructive preset) | Product | BU-08 alternative | + +--- + +## 12. Traceability + +| Source doc | Relationship | +|------------|--------------| +| `GUI_V2_REQUIREMENTS.md` | Parity inventory §3.1; v2 phased plan **cancelled** | +| `V0.2_GUI_REDESIGN.md` | Historical v1 layout intent | +| `docs/gui_architecture.md` | Wire contract | +| `docs/HANDOFF.md` | Session state + design review quote | +| Stash `wip: main GUI v2 + build_viz` | Cherry-pick → v3 Build | +| **Concept art** | [`docs/design/assets/`](assets/) — 6 PNG mockups | + +--- + +## 13. ADR + +**Accepted:** [`GUI_V3_ADR.md`](GUI_V3_ADR.md) — incl. **ADR-011 Scenario-first**. + + +*End of v3 requirements baseline.* diff --git a/docs/design/STRUCTURED_REVIEW.md b/docs/design/STRUCTURED_REVIEW.md index 1f4127fd..c5fd7d33 100644 --- a/docs/design/STRUCTURED_REVIEW.md +++ b/docs/design/STRUCTURED_REVIEW.md @@ -12,6 +12,7 @@ implementations minimal after review. | Lane | Scope | Self-review hook | |------|--------|------------------| | **A — Validation** | ISO/Chrono parity gates | `ctest -R 'ChronoKcParity|IsoBaseline'` | +| **A — Plant** | `python/vdsim_plant.py`, `VLA_THESIS_PLANT.md`, `test_vla_plant*` | `ctest -R VlaPlant`; `python3 tests/scripts/test_vla_plant.py` | | **B — Core session** | `make_direct_control_session`, CmdL1, `TireSetup`, friction ground | `ctest -R 'PerWheel|PerAxle|VlaPlant|SimSession'` | | **C — Catalog/GUI** | `assembly.py`, `part_cards.py`, `catalog_bridge.py` | `ctest -R 'catalog|assembly|blueprint|multi_vehicle'` | @@ -59,6 +60,6 @@ After bug fixes: ## Acceptance - All lane tests **PASS** -- Full `ctest` green (388 on `main`, 402 on `VDSim-Thesis`) +- Full `ctest` green (count PENDING — re-measured on the integration tree) - Review report committed or attached to PR - No ISO/benchmark number re-baseline unless explicitly approved diff --git a/docs/design/assets/README.md b/docs/design/assets/README.md new file mode 100644 index 00000000..50a9f0f9 --- /dev/null +++ b/docs/design/assets/README.md @@ -0,0 +1,19 @@ +# GUI v3 — concept art + +Target UI mockups for [`GUI_V3_REQUIREMENTS.md`](../GUI_V3_REQUIREMENTS.md) and +[`GUI_V3_ADR.md`](../GUI_V3_ADR.md). AI-generated reference images (not pixel-perfect spec). + +**Do not use in thesis or onboarding without a live capture side-by-side** — several +panels (Scenario waypoint table, Run force triads, Build drawer) differ from or exceed +what is implemented. See [`GUI_V3_VISUAL_REVIEW.md`](../../evidence/review/GUI_V3_VISUAL_REVIEW.md). + +| File | Mode | Description | +|------|------|-------------| +| `vdsim-v3-scenario-studio-concept.png` | **Scenario** (P0) | Map + path + fleet inspector — default landing | +| `vdsim-v3-run-mode-concept.png` | **Run** (P1) | Three.js viewport + telemetry | +| `vdsim-v3-build-parts-assembly-concept.png` | **Build** | Danawa slot list + schematic + part cards | +| `vdsim-v3-build-drawer-from-scenario-concept.png` | **Build** drawer | Edit parts without leaving Scenario | +| `vdsim-v3-l4-hardpoint-editor-concept.png` | **L4 hardpoints** | DW linkage editor + K&C live curves | +| `vdsim-v3-l4-hardpoint-pick-mode-concept.png` | **L4 pick** | Drag hardpoint on schematic | + +Implementation: `gui/v3/` (Svelte 5 + Vite). diff --git a/docs/design/assets/vdsim-v3-build-drawer-from-scenario-concept.png b/docs/design/assets/vdsim-v3-build-drawer-from-scenario-concept.png new file mode 100644 index 00000000..dd31895b Binary files /dev/null and b/docs/design/assets/vdsim-v3-build-drawer-from-scenario-concept.png differ diff --git a/docs/design/assets/vdsim-v3-build-parts-assembly-concept.png b/docs/design/assets/vdsim-v3-build-parts-assembly-concept.png new file mode 100644 index 00000000..125998bb Binary files /dev/null and b/docs/design/assets/vdsim-v3-build-parts-assembly-concept.png differ diff --git a/docs/design/assets/vdsim-v3-l4-hardpoint-editor-concept.png b/docs/design/assets/vdsim-v3-l4-hardpoint-editor-concept.png new file mode 100644 index 00000000..49291801 Binary files /dev/null and b/docs/design/assets/vdsim-v3-l4-hardpoint-editor-concept.png differ diff --git a/docs/design/assets/vdsim-v3-l4-hardpoint-pick-mode-concept.png b/docs/design/assets/vdsim-v3-l4-hardpoint-pick-mode-concept.png new file mode 100644 index 00000000..47f69aa0 Binary files /dev/null and b/docs/design/assets/vdsim-v3-l4-hardpoint-pick-mode-concept.png differ diff --git a/docs/design/assets/vdsim-v3-run-mode-concept.png b/docs/design/assets/vdsim-v3-run-mode-concept.png new file mode 100644 index 00000000..1fb15d62 Binary files /dev/null and b/docs/design/assets/vdsim-v3-run-mode-concept.png differ diff --git a/docs/design/assets/vdsim-v3-scenario-studio-concept.png b/docs/design/assets/vdsim-v3-scenario-studio-concept.png new file mode 100644 index 00000000..a65de8fa Binary files /dev/null and b/docs/design/assets/vdsim-v3-scenario-studio-concept.png differ diff --git a/docs/evidence/review/2026-06-22/GUI_V3_EVAL_BUNDLE.md b/docs/evidence/review/2026-06-22/GUI_V3_EVAL_BUNDLE.md new file mode 100644 index 00000000..1d7fd06f --- /dev/null +++ b/docs/evidence/review/2026-06-22/GUI_V3_EVAL_BUNDLE.md @@ -0,0 +1,22 @@ +# GUI v3 eval bundle — 2026-06-22 + +Overall automation: **PASS** +P0 parity gaps (static): **yes** + +| Step | Exit | +|------|------| +| npm_check | 0 | +| npm_build | 0 | +| gui_v3_api_smoke | 0 | +| gui_v3_e2e | 0 | +| parity_audit | 1 | +| ctest_assembly | 0 | +| ctest_catalog | 0 | +| ctest_compare | 0 | + +Artifacts: +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-22/gui_v3_parity_matrix.json` +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-22/GUI_V3_PARITY_AUDIT.md` +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-22/gui_v3_eval_bundle.json` + +Run: `python3 tools/gui_v3_eval_bundle.py` diff --git a/docs/evidence/review/2026-06-22/GUI_V3_PARITY_AUDIT.md b/docs/evidence/review/2026-06-22/GUI_V3_PARITY_AUDIT.md new file mode 100644 index 00000000..707a528c --- /dev/null +++ b/docs/evidence/review/2026-06-22/GUI_V3_PARITY_AUDIT.md @@ -0,0 +1,63 @@ +# GUI v3 parity audit — 2026-06-22 + +Automated static audit (no browser). P0 gaps: **3** + +| Status | Count | +|--------|-------| +| PASS | 38 | +| PARTIAL | 2 | +| GAP | 8 | + +| ID | Prio | Status | Note | +|----|------|--------|------| +| SH-01 | P0 | PASS | hash routes scenario/run/build/analyze | +| SH-02 | P0 | PASS | scenario save/load in ScenarioMode | +| SH-03 | P0 | PASS | Play → run | +| SH-04 | P0 | PARTIAL | kin warnings in Run status only | +| SH-05 | P0 | GAP | mkdocs [?] help links not wired | +| SH-06 | P1 | GAP | panel resize persist | +| CO-00 | P0 | GAP | template gallery | +| CO-01 | P0 | PASS | road μ/grade/bank | +| CO-02 | P1 | GAP | OpenDRIVE xodr | +| CO-03 | P1 | GAP | terrain obj | +| CO-04 | P0 | PASS | fleet table | +| CO-05 | P0 | PASS | map drag | +| CO-06 | P0 | PASS | path preset | +| CO-07 | P0 | PASS | path edit | +| CO-08 | P0 | PASS | static map path; Run viewport separate | +| CO-09 | P1 | GAP | cosim attach UI | +| CO-14 | P1 | PARTIAL | scenario list from API | +| CO-15 | P0 | PASS | build?vid= | +| CO-16 | P0 | PASS | save/load scenario | +| BU-01 | P0 | PASS | blueprint presets | +| BU-02 | P0 | PASS | categories | +| BU-03 | P0 | PASS | preview Δ + install | +| BU-04 | P0 | PASS | stats panel | +| BU-05 | P0 | PASS | build_complete surfaced | +| BU-06 | P0 | PASS | L1–L5 | +| BU-07 | P1 | PASS | compat badges | +| BU-08 | P0 | PASS | blueprint confirm | +| BU-09 | P1 | PASS | export blueprint | +| BU-10 | P0 | PASS | linkage SVG | +| BU-11 | P0 | PASS | no Three.js in BuildMode | +| BU-13 | P2 | PASS | K&C in HardpointEditor | +| BU-14 | P1 | PASS | PartsLibrary | +| BU-HP | P0 | PASS | HardpointEditor | +| RN-00 | P0 | PASS | Run mode route | +| RN-01 | P0 | PASS | stop/reset | +| RN-02 | P0 | GAP | autopilot/manual + manbar | +| RN-03 | P0 | PASS | time scale | +| RN-04 | P0 | PASS | cam modes | +| RN-05 | P0 | PASS | SSE viewport | +| RN-06 | P1 | GAP | wheel force triad | +| RN-07 | P0 | PASS | telemetry | +| AN-01 | P0 | PASS | fleet/catalog picks | +| AN-02 | P0 | PASS | maneuvers | +| AN-03 | P0 | PASS | yaw chart | +| AN-04 | P0 | PASS | delta table | +| AN-05 | P1 | PASS | CSV export | +| AN-06 | P0 | PASS | separate Analyze mode route | +| NF-BUILD | P0 | PASS | v3 dist present | + +Regenerate: `python3 tools/gui_v3_parity_audit.py` +API smoke: `python3 tests/scripts/test_gui_v3_api_smoke.py` diff --git a/docs/evidence/review/2026-06-22/gui_v3_eval_bundle.json b/docs/evidence/review/2026-06-22/gui_v3_eval_bundle.json new file mode 100644 index 00000000..3e189d03 --- /dev/null +++ b/docs/evidence/review/2026-06-22/gui_v3_eval_bundle.json @@ -0,0 +1,62 @@ +{ + "date": "2026-06-22", + "steps": [ + { + "cmd": "npm run check", + "exit_code": 0, + "stdout_tail": "\n/home/ailab-12/git/VDSim/gui/v3/tsconfig.app.json:18:5\nWarn: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '\"ignoreDeprecations\": \"6.0\"' to silence this error.\n Visit https://aka.ms/ts6 for migration information. \n\n\n====================================\nsvelte-check found 0 errors and 2 warnings in 2 files", + "stderr_tail": "", + "step": "npm_check" + }, + { + "cmd": "npm run build", + "exit_code": 0, + "stdout_tail": "transforming...\u2713 148 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB \u2502 gzip: 0.30 kB\ndist/assets/index-C5rlO4KD.css 21.98 kB \u2502 gzip: 3.98 kB\ndist/assets/index-DZFB-5Nr.js 600.49 kB \u2502 gzip: 162.53 kB\n\n\u2713 built in 1.18s", + "stderr_tail": " ^\n281: cx={vpX(vp, p[1])}\n282: cy={vpY(vp, p[2])}\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "step": "npm_build" + }, + { + "cmd": "/usr/bin/python3 tests/scripts/test_gui_v3_api_smoke.py", + "exit_code": 0, + "stdout_tail": "ok gui_v3_api_smoke", + "stderr_tail": "", + "step": "gui_v3_api_smoke" + }, + { + "cmd": "/usr/bin/python3 tests/scripts/test_gui_v3_e2e.py", + "exit_code": 0, + "stdout_tail": "\n \u2713 1 e2e/smoke.spec.ts:17:3 \u203a GUI v3 smoke \u203a scenario map and fleet (166ms)\n \u2713 2 e2e/smoke.spec.ts:23:3 \u203a GUI v3 smoke \u203a build assembly (954ms)\n \u2713 3 e2e/smoke.spec.ts:30:3 \u203a GUI v3 smoke \u203a analyze compare panel (118ms)\n \u2713 4 e2e/smoke.spec.ts:36:3 \u203a GUI v3 smoke \u203a run viewport shell (269ms)\n\n 4 passed (1.9s)\nok gui_v3_e2e", + "stderr_tail": "(node:1471308) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n(Use `node --trace-warnings ...` to show where the warning was created)", + "step": "gui_v3_e2e" + }, + { + "cmd": "/usr/bin/python3 tools/gui_v3_parity_audit.py --out-dir /home/ailab-12/git/VDSim/docs/evidence/review/2026-06-22", + "exit_code": 1, + "stdout_tail": "/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-22/GUI_V3_PARITY_AUDIT.md\nP0 gaps: 3 PASS/PARTIAL/GAP = 38/2/8", + "stderr_tail": "", + "step": "parity_audit" + }, + { + "cmd": "ctest -R assembly --output-on-failure", + "exit_code": 0, + "stdout_tail": "Test project /home/ailab-12/git/VDSim/build\n Start 395: assembly_api\n1/1 Test #395: assembly_api ..................... Passed 1.23 sec\n\n100% tests passed, 0 tests failed out of 1\n\nTotal Test time (real) = 1.24 sec", + "stderr_tail": "", + "step": "ctest_assembly" + }, + { + "cmd": "ctest -R catalog --output-on-failure", + "exit_code": 0, + "stdout_tail": " Start 389: catalog_resolver\n1/2 Test #389: catalog_resolver ................. Passed 0.19 sec\n Start 391: catalog_api\n2/2 Test #391: catalog_api ...................... Passed 0.31 sec\n\n100% tests passed, 0 tests failed out of 2\n\nTotal Test time (real) = 0.50 sec", + "stderr_tail": "", + "step": "ctest_catalog" + }, + { + "cmd": "ctest -R multi_vehicle_compare --output-on-failure", + "exit_code": 0, + "stdout_tail": "Test project /home/ailab-12/git/VDSim/build\n Start 398: multi_vehicle_compare\n1/1 Test #398: multi_vehicle_compare ............ Passed 0.96 sec\n\n100% tests passed, 0 tests failed out of 1\n\nTotal Test time (real) = 0.97 sec", + "stderr_tail": "", + "step": "ctest_compare" + } + ], + "p0_gaps_blocking": true +} diff --git a/docs/evidence/review/2026-06-22/gui_v3_parity_matrix.json b/docs/evidence/review/2026-06-22/gui_v3_parity_matrix.json new file mode 100644 index 00000000..a4b63257 --- /dev/null +++ b/docs/evidence/review/2026-06-22/gui_v3_parity_matrix.json @@ -0,0 +1,348 @@ +{ + "date": "2026-06-22", + "tool": "gui_v3_parity_audit.py", + "counts": { + "PASS": 38, + "PARTIAL": 2, + "GAP": 8 + }, + "p0_gaps": 3, + "checks": [ + { + "id": "SH-01", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "hash routes scenario/run/build/analyze" + }, + { + "id": "SH-02", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "scenario save/load in ScenarioMode" + }, + { + "id": "SH-03", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "Play \u2192 run" + }, + { + "id": "SH-04", + "group": "shell", + "prio": "P0", + "status": "PARTIAL", + "note": "kin warnings in Run status only" + }, + { + "id": "SH-05", + "group": "shell", + "prio": "P0", + "status": "GAP", + "note": "mkdocs [?] help links not wired" + }, + { + "id": "SH-06", + "group": "shell", + "prio": "P1", + "status": "GAP", + "note": "panel resize persist" + }, + { + "id": "CO-00", + "group": "scenario", + "prio": "P0", + "status": "GAP", + "note": "template gallery" + }, + { + "id": "CO-01", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "road \u03bc/grade/bank" + }, + { + "id": "CO-02", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "OpenDRIVE xodr" + }, + { + "id": "CO-03", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "terrain obj" + }, + { + "id": "CO-04", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "fleet table" + }, + { + "id": "CO-05", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "map drag" + }, + { + "id": "CO-06", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "path preset" + }, + { + "id": "CO-07", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "path edit" + }, + { + "id": "CO-08", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "static map path; Run viewport separate" + }, + { + "id": "CO-09", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "cosim attach UI" + }, + { + "id": "CO-14", + "group": "scenario", + "prio": "P1", + "status": "PARTIAL", + "note": "scenario list from API" + }, + { + "id": "CO-15", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "build?vid=" + }, + { + "id": "CO-16", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "save/load scenario" + }, + { + "id": "BU-01", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "blueprint presets" + }, + { + "id": "BU-02", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "categories" + }, + { + "id": "BU-03", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "preview \u0394 + install" + }, + { + "id": "BU-04", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "stats panel" + }, + { + "id": "BU-05", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "build_complete surfaced" + }, + { + "id": "BU-06", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "L1\u2013L5" + }, + { + "id": "BU-07", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "compat badges" + }, + { + "id": "BU-08", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "blueprint confirm" + }, + { + "id": "BU-09", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "export blueprint" + }, + { + "id": "BU-10", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "linkage SVG" + }, + { + "id": "BU-11", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "no Three.js in BuildMode" + }, + { + "id": "BU-13", + "group": "build", + "prio": "P2", + "status": "PASS", + "note": "K&C in HardpointEditor" + }, + { + "id": "BU-14", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "PartsLibrary" + }, + { + "id": "BU-HP", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "HardpointEditor" + }, + { + "id": "RN-00", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "Run mode route" + }, + { + "id": "RN-01", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "stop/reset" + }, + { + "id": "RN-02", + "group": "run", + "prio": "P0", + "status": "GAP", + "note": "autopilot/manual + manbar" + }, + { + "id": "RN-03", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "time scale" + }, + { + "id": "RN-04", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "cam modes" + }, + { + "id": "RN-05", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "SSE viewport" + }, + { + "id": "RN-06", + "group": "run", + "prio": "P1", + "status": "GAP", + "note": "wheel force triad" + }, + { + "id": "RN-07", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "telemetry" + }, + { + "id": "AN-01", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "fleet/catalog picks" + }, + { + "id": "AN-02", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "maneuvers" + }, + { + "id": "AN-03", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "yaw chart" + }, + { + "id": "AN-04", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "delta table" + }, + { + "id": "AN-05", + "group": "analyze", + "prio": "P1", + "status": "PASS", + "note": "CSV export" + }, + { + "id": "AN-06", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "separate Analyze mode route" + }, + { + "id": "NF-BUILD", + "group": "nonfunctional", + "prio": "P0", + "status": "PASS", + "note": "v3 dist present" + } + ] +} diff --git a/docs/evidence/review/2026-06-23/GUI_V3_EVAL_BUNDLE.md b/docs/evidence/review/2026-06-23/GUI_V3_EVAL_BUNDLE.md new file mode 100644 index 00000000..cad80c53 --- /dev/null +++ b/docs/evidence/review/2026-06-23/GUI_V3_EVAL_BUNDLE.md @@ -0,0 +1,22 @@ +# GUI v3 eval bundle — 2026-06-23 + +Overall automation: **PASS** +P0 parity gaps (static): **yes** + +| Step | Exit | +|------|------| +| npm_check | 0 | +| npm_build | 0 | +| gui_v3_api_smoke | 0 | +| gui_v3_e2e | 0 | +| parity_audit | 1 | +| ctest_assembly | 0 | +| ctest_catalog | 0 | +| ctest_compare | 0 | + +Artifacts: +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-23/gui_v3_parity_matrix.json` +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-23/GUI_V3_PARITY_AUDIT.md` +- `/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-23/gui_v3_eval_bundle.json` + +Run: `python3 tools/gui_v3_eval_bundle.py` diff --git a/docs/evidence/review/2026-06-23/GUI_V3_PARITY_AUDIT.md b/docs/evidence/review/2026-06-23/GUI_V3_PARITY_AUDIT.md new file mode 100644 index 00000000..43e5bc12 --- /dev/null +++ b/docs/evidence/review/2026-06-23/GUI_V3_PARITY_AUDIT.md @@ -0,0 +1,63 @@ +# GUI v3 parity audit — 2026-06-23 + +Automated static audit (no browser). P0 gaps: **0** + +| Status | Count | +|--------|-------| +| PASS | 42 | +| PARTIAL | 2 | +| GAP | 4 | + +| ID | Prio | Status | Note | +|----|------|--------|------| +| SH-01 | P0 | PASS | hash routes scenario/run/build/analyze | +| SH-02 | P0 | PASS | scenario save/load in ScenarioMode | +| SH-03 | P0 | PASS | Play → run | +| SH-04 | P0 | PARTIAL | kin warnings in Run status only | +| SH-05 | P0 | PASS | mkdocs help links | +| SH-06 | P1 | GAP | panel resize persist | +| CO-00 | P0 | PASS | template gallery | +| CO-01 | P0 | PASS | road μ/grade/bank | +| CO-02 | P1 | GAP | OpenDRIVE xodr | +| CO-03 | P1 | GAP | terrain obj | +| CO-04 | P0 | PASS | fleet table | +| CO-05 | P0 | PASS | map drag | +| CO-06 | P0 | PASS | path preset + wp table | +| CO-07 | P0 | PASS | path edit | +| CO-08 | P0 | PASS | static map path; Run viewport separate | +| CO-09 | P1 | GAP | cosim attach UI | +| CO-14 | P1 | PARTIAL | scenario list from API | +| CO-15 | P0 | PASS | build?vid= | +| CO-16 | P0 | PASS | save/load scenario | +| BU-01 | P0 | PASS | blueprint presets | +| BU-02 | P0 | PASS | categories | +| BU-03 | P0 | PASS | preview Δ + install | +| BU-04 | P0 | PASS | stats panel | +| BU-05 | P0 | PASS | build_complete surfaced | +| BU-06 | P0 | PASS | L1–L5 | +| BU-07 | P1 | PASS | compat badges | +| BU-08 | P0 | PASS | blueprint confirm | +| BU-09 | P1 | PASS | export blueprint | +| BU-10 | P0 | PASS | linkage SVG | +| BU-11 | P0 | PASS | no Three.js in BuildMode | +| BU-13 | P2 | PASS | K&C in HardpointEditor | +| BU-14 | P1 | PASS | PartsLibrary | +| BU-HP | P0 | PASS | HardpointEditor | +| RN-00 | P0 | PASS | Run mode route | +| RN-01 | P0 | PASS | stop/reset | +| RN-02 | P0 | PASS | autopilot/manual + manbar | +| RN-03 | P0 | PASS | time scale | +| RN-04 | P0 | PASS | cam modes | +| RN-05 | P0 | PASS | SSE viewport | +| RN-06 | P1 | PASS | wheel force triad | +| RN-07 | P0 | PASS | telemetry | +| AN-01 | P0 | PASS | fleet/catalog picks | +| AN-02 | P0 | PASS | maneuvers | +| AN-03 | P0 | PASS | yaw chart | +| AN-04 | P0 | PASS | delta table | +| AN-05 | P1 | PASS | CSV export | +| AN-06 | P0 | PASS | separate Analyze mode route | +| NF-BUILD | P0 | PASS | v3 dist present | + +Regenerate: `python3 tools/gui_v3_parity_audit.py` +API smoke: `python3 tests/scripts/test_gui_v3_api_smoke.py` diff --git a/docs/evidence/review/2026-06-23/gui_v3_eval_bundle.json b/docs/evidence/review/2026-06-23/gui_v3_eval_bundle.json new file mode 100644 index 00000000..8e7bf0f9 --- /dev/null +++ b/docs/evidence/review/2026-06-23/gui_v3_eval_bundle.json @@ -0,0 +1,62 @@ +{ + "date": "2026-06-23", + "steps": [ + { + "cmd": "npm run check", + "exit_code": 0, + "stdout_tail": "\n/home/ailab-12/git/VDSim/gui/v3/tsconfig.app.json:18:5\nWarn: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '\"ignoreDeprecations\": \"6.0\"' to silence this error.\n Visit https://aka.ms/ts6 for migration information. \n\n\n====================================\nsvelte-check found 0 errors and 2 warnings in 2 files", + "stderr_tail": "", + "step": "npm_check" + }, + { + "cmd": "npm run build", + "exit_code": 0, + "stdout_tail": "transforming...\u2713 149 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.46 kB \u2502 gzip: 0.29 kB\ndist/assets/index-fFVnJ28u.css 23.06 kB \u2502 gzip: 4.11 kB\ndist/assets/index-xlL7aTFL.js 606.49 kB \u2502 gzip: 164.36 kB\n\n\u2713 built in 2.50s", + "stderr_tail": " ^\n281: cx={vpX(vp, p[1])}\n282: cy={vpY(vp, p[2])}\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.", + "step": "npm_build" + }, + { + "cmd": "/usr/bin/python3 tests/scripts/test_gui_v3_api_smoke.py", + "exit_code": 0, + "stdout_tail": "ok gui_v3_api_smoke", + "stderr_tail": "", + "step": "gui_v3_api_smoke" + }, + { + "cmd": "/usr/bin/python3 tests/scripts/test_gui_v3_e2e.py", + "exit_code": 0, + "stdout_tail": " \u2713 1 e2e/capture-review.spec.ts:23:3 \u203a GUI capture review \u203a capture user flow (37.4s)\n \u2713 2 e2e/smoke.spec.ts:17:3 \u203a GUI v3 smoke \u203a scenario map and fleet (370ms)\n \u2713 3 e2e/smoke.spec.ts:23:3 \u203a GUI v3 smoke \u203a build assembly (888ms)\n \u2713 4 e2e/smoke.spec.ts:30:3 \u203a GUI v3 smoke \u203a analyze compare panel (97ms)\n \u2713 5 e2e/smoke.spec.ts:36:3 \u203a GUI v3 smoke \u203a run viewport shell (350ms)\n\n 5 passed (39.6s)\nok gui_v3_e2e", + "stderr_tail": "(node:2289654) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n(Use `node --trace-warnings ...` to show where the warning was created)", + "step": "gui_v3_e2e" + }, + { + "cmd": "/usr/bin/python3 tools/gui_v3_parity_audit.py --out-dir /home/ailab-12/git/VDSim/docs/evidence/review/2026-06-23", + "exit_code": 1, + "stdout_tail": "/home/ailab-12/git/VDSim/docs/evidence/review/2026-06-23/GUI_V3_PARITY_AUDIT.md\nP0 gaps: 3 PASS/PARTIAL/GAP = 39/2/7", + "stderr_tail": "", + "step": "parity_audit" + }, + { + "cmd": "ctest -R assembly --output-on-failure", + "exit_code": 0, + "stdout_tail": "Test project /home/ailab-12/git/VDSim/build\n Start 395: assembly_api\n1/1 Test #395: assembly_api ..................... Passed 1.10 sec\n\n100% tests passed, 0 tests failed out of 1\n\nTotal Test time (real) = 1.10 sec", + "stderr_tail": "", + "step": "ctest_assembly" + }, + { + "cmd": "ctest -R catalog --output-on-failure", + "exit_code": 0, + "stdout_tail": " Start 389: catalog_resolver\n1/2 Test #389: catalog_resolver ................. Passed 0.16 sec\n Start 391: catalog_api\n2/2 Test #391: catalog_api ...................... Passed 0.27 sec\n\n100% tests passed, 0 tests failed out of 2\n\nTotal Test time (real) = 0.43 sec", + "stderr_tail": "", + "step": "ctest_catalog" + }, + { + "cmd": "ctest -R multi_vehicle_compare --output-on-failure", + "exit_code": 0, + "stdout_tail": "Test project /home/ailab-12/git/VDSim/build\n Start 398: multi_vehicle_compare\n1/1 Test #398: multi_vehicle_compare ............ Passed 0.86 sec\n\n100% tests passed, 0 tests failed out of 1\n\nTotal Test time (real) = 0.87 sec", + "stderr_tail": "", + "step": "ctest_compare" + } + ], + "p0_gaps_blocking": true +} diff --git a/docs/evidence/review/2026-06-23/gui_v3_parity_matrix.json b/docs/evidence/review/2026-06-23/gui_v3_parity_matrix.json new file mode 100644 index 00000000..1a57443f --- /dev/null +++ b/docs/evidence/review/2026-06-23/gui_v3_parity_matrix.json @@ -0,0 +1,348 @@ +{ + "date": "2026-06-23", + "tool": "gui_v3_parity_audit.py", + "counts": { + "PASS": 42, + "PARTIAL": 2, + "GAP": 4 + }, + "p0_gaps": 0, + "checks": [ + { + "id": "SH-01", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "hash routes scenario/run/build/analyze" + }, + { + "id": "SH-02", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "scenario save/load in ScenarioMode" + }, + { + "id": "SH-03", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "Play \u2192 run" + }, + { + "id": "SH-04", + "group": "shell", + "prio": "P0", + "status": "PARTIAL", + "note": "kin warnings in Run status only" + }, + { + "id": "SH-05", + "group": "shell", + "prio": "P0", + "status": "PASS", + "note": "mkdocs help links" + }, + { + "id": "SH-06", + "group": "shell", + "prio": "P1", + "status": "GAP", + "note": "panel resize persist" + }, + { + "id": "CO-00", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "template gallery" + }, + { + "id": "CO-01", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "road \u03bc/grade/bank" + }, + { + "id": "CO-02", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "OpenDRIVE xodr" + }, + { + "id": "CO-03", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "terrain obj" + }, + { + "id": "CO-04", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "fleet table" + }, + { + "id": "CO-05", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "map drag" + }, + { + "id": "CO-06", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "path preset + wp table" + }, + { + "id": "CO-07", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "path edit" + }, + { + "id": "CO-08", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "static map path; Run viewport separate" + }, + { + "id": "CO-09", + "group": "scenario", + "prio": "P1", + "status": "GAP", + "note": "cosim attach UI" + }, + { + "id": "CO-14", + "group": "scenario", + "prio": "P1", + "status": "PARTIAL", + "note": "scenario list from API" + }, + { + "id": "CO-15", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "build?vid=" + }, + { + "id": "CO-16", + "group": "scenario", + "prio": "P0", + "status": "PASS", + "note": "save/load scenario" + }, + { + "id": "BU-01", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "blueprint presets" + }, + { + "id": "BU-02", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "categories" + }, + { + "id": "BU-03", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "preview \u0394 + install" + }, + { + "id": "BU-04", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "stats panel" + }, + { + "id": "BU-05", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "build_complete surfaced" + }, + { + "id": "BU-06", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "L1\u2013L5" + }, + { + "id": "BU-07", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "compat badges" + }, + { + "id": "BU-08", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "blueprint confirm" + }, + { + "id": "BU-09", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "export blueprint" + }, + { + "id": "BU-10", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "linkage SVG" + }, + { + "id": "BU-11", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "no Three.js in BuildMode" + }, + { + "id": "BU-13", + "group": "build", + "prio": "P2", + "status": "PASS", + "note": "K&C in HardpointEditor" + }, + { + "id": "BU-14", + "group": "build", + "prio": "P1", + "status": "PASS", + "note": "PartsLibrary" + }, + { + "id": "BU-HP", + "group": "build", + "prio": "P0", + "status": "PASS", + "note": "HardpointEditor" + }, + { + "id": "RN-00", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "Run mode route" + }, + { + "id": "RN-01", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "stop/reset" + }, + { + "id": "RN-02", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "autopilot/manual + manbar" + }, + { + "id": "RN-03", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "time scale" + }, + { + "id": "RN-04", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "cam modes" + }, + { + "id": "RN-05", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "SSE viewport" + }, + { + "id": "RN-06", + "group": "run", + "prio": "P1", + "status": "PASS", + "note": "wheel force triad" + }, + { + "id": "RN-07", + "group": "run", + "prio": "P0", + "status": "PASS", + "note": "telemetry" + }, + { + "id": "AN-01", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "fleet/catalog picks" + }, + { + "id": "AN-02", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "maneuvers" + }, + { + "id": "AN-03", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "yaw chart" + }, + { + "id": "AN-04", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "delta table" + }, + { + "id": "AN-05", + "group": "analyze", + "prio": "P1", + "status": "PASS", + "note": "CSV export" + }, + { + "id": "AN-06", + "group": "analyze", + "prio": "P0", + "status": "PASS", + "note": "separate Analyze mode route" + }, + { + "id": "NF-BUILD", + "group": "nonfunctional", + "prio": "P0", + "status": "PASS", + "note": "v3 dist present" + } + ] +} diff --git a/docs/evidence/review/GUI_V3_VISUAL_REVIEW.md b/docs/evidence/review/GUI_V3_VISUAL_REVIEW.md new file mode 100644 index 00000000..6a398d01 --- /dev/null +++ b/docs/evidence/review/GUI_V3_VISUAL_REVIEW.md @@ -0,0 +1,139 @@ +# GUI v3 — visual review vs concept art + +Date: **2026-06-22** +Method: Playwright headless user flow + `gui/server.py` (ephemeral port) +Captures: [`gui-captures/`](gui-captures/) +Concept art: [`docs/design/assets/`](../../design/assets/README.md) + +## Executive verdict (cold) + +| Lens | Score | Comment | +|------|-------|---------| +| vs **concept art** (target UX) | **4/10** | IA matches; visual storytelling and “studio” density do not | +| vs **v1 external review** (~4/10 prototype) | **6/10** | Mode shell + no modals is real progress; still reads wireframe | +| **Analyze** (no dedicated concept PNG) | **7/10** | Yaw overlay + Δ% table deliver the validation story | +| **Concept art as docs** | **5/10** | Good north-star; risk of overselling if shown without “not implemented” labels | + +**Bottom line:** Concept illustrations are **not** adequately matched by the current GUI for onboarding or thesis figures. They work as **aspirational** art only. For honest communication, pair each concept PNG with a **live capture** side-by-side or mark concept as “target”. + +--- + +## Capture inventory + +| File | Flow step | +|------|-----------| +| `01-scenario-l4.png` | Scenario, `l4_sedan_kinematics` loaded | +| `02-build-default.png` | Build, L4, body slot | +| `03-build-l4-hardpoint.png` | Build, front chassis + HP editor | +| `04-analyze-idle.png` | Analyze, vehicle picks | +| `05-analyze-results.png` | Compare: sedan_l3 vs fsk_formula, step-steer chart | +| `06-run-sim-t3.png` | Run after ▶ Play (~3 s sim) | +| `07-run-sim-t8.png` | Run ~8 s sim | +| `06-run-idle.png` | (legacy) idle before Play fix | + +Regenerate: `python3 tools/gui_v3_capture.py` + +--- + +## Mode-by-mode vs concept art + +### Scenario — `vdsim-v3-scenario-studio-concept.png` + +| Concept shows | Implementation shows | Gap | +|---------------|---------------------|-----| +| Figure-8 **drawn on map**, numbered waypoints 1–6 | Path line + 160 pts; edit mode shows wp # | **Improved** (was P0 empty) | +| Grid axes −80…80 m, compass ISO 8855 | Grid + **axis tick labels (m)** | Medium (no compass) | +| Floating map toolbar (select, draw, waypoint) | μ/grade/bank/v + “Edit path” only | Medium | +| Waypoint **table** (x, y, heading, speed) | **Downsampled x,y table** + map edit | Medium (no heading/speed cols) | +| Two agents on track | One agent; map marker minimal | Small | + +**Qualitative:** A new user cannot infer “scenario studio” from the capture. The concept art **does** explain the intended workflow; the app **does not** yet illustrate path/fleet composition visually. + +--- + +### Run — `vdsim-v3-run-mode-concept.png` + +| Concept shows | Implementation shows | Gap | +|---------------|---------------------|-----| +| Chase camera on **mesh** sedan, track environment | Idle: **preview mesh** at spawn; sim captures show chase | Medium (RN-06 forces still GAP) | +| Per-wheel **force arrows** (Fx/Fy/Fz) | **Fx/Fy/Fz triad** at wheels when sim running | **Closed** (RN-06) | +| Telemetry: pose, velocity, **per-wheel slip table** | Pose + **FL–RR table** (Fz, Fx, Fy, κ, α°) + sim **t** | Medium (no timeline scrub) | +| Timeline scrubber, 1.0x transport | Stop/Reset + time scale; **no timeline bar** | Medium | +| “Running” + sim time | Capture is pre-Play (`06-run-idle.png`) | Test gap | + +**Qualitative:** Concept sells “runtime engineering viewer.” Current Run is a **credible layout** but the illustration layer (car + forces + environment) is missing — the screenshot looks **empty**, not “simulation.” + +--- + +### Build — `vdsim-v3-build-parts-assembly-concept.png` + +| Concept shows | Implementation shows | Gap | +|---------------|---------------------|-----| +| **L3 7-DOF** annotated schematic (masses, springs, dims) | Block diagram + **minimal y–z linkage SVG** | Medium–large | +| Breadcrumb `Scenario > Agent 0` | Vehicle build header + V0 chip only | Small | +| Part cards with **tire images**, COMPATIBLE badges | Text cards, tier label; compat via border tone | Medium | +| Hover **Δ mass** on cards | Preview Δ in stats panel (good) | OK | +| `build_complete: true` footer strip | Incomplete warning only when false | Small | + +**Qualitative:** **Best structural match** among modes — three columns, slots, schematic, stats. Illustrations are **schematic-minimal** vs concept’s textbook diagram; still defensible for L4 **if** linkage SVG is labeled as side-view kinematics. + +--- + +### L4 hardpoints — `vdsim-v3-l4-hardpoint-editor-concept.png`, pick-mode PNG + +| Concept shows | Implementation shows | Gap | +|---------------|---------------------|-----| +| Full DW **side view** with dimension Δx/Δz | Line segments + draggable dots; no dims on canvas | Medium | +| Hardpoint **tree** (lca/uca/…) | Flat pick list (short names) | Small | +| Status: “18 DOF valid”, loop closure | Not surfaced in UI | Medium | +| Live K&C with “native solver” badge | Two small K&C charts — **functionally present** | **Smallest gap** | +| Revert / Save YAML header actions | Save & install only | Small | + +**Qualitative:** Only area where **function ≈ concept**. Visual polish is far below mockup; for thesis/docs, **live HP screenshot is more honest than concept art** here. + +--- + +### Build drawer from Scenario — `vdsim-v3-build-drawer-from-scenario-concept.png` + +| Concept | Implementation | +|---------|----------------| +| Drawer overlay on map, edit parts in context | **Not implemented** — `Edit parts →` navigates to `#/build` full page | + +**Qualitative:** Concept art describes a UX that **does not exist**; do not use this PNG without caption “future”. + +--- + +### Analyze (no concept PNG) + +Capture `05-analyze-results.png`: fleet + catalog picks, maneuver toggles, yaw-rate overlay, Δ% table — **matches requirements AN-01–AN-04** well. This mode needs its own concept art or reuse compare dashboard from v1; current UI is **self-explanatory** without illustration. + +--- + +## Are illustrations “sufficient” for explanation? + +| Audience | Concept art alone | Live GUI today | Recommendation | +|----------|-------------------|----------------|----------------| +| Thesis committee | **Insufficient** (oversells) | **Partial** (Analyze/Build OK; Scenario/Run weak) | Side-by-side **concept | capture | gap** figure | +| New OSS user | Misleading for map/Run | Needs tooltips + path on map | Fix path render; add one annotated screenshot tour | +| Internal team | Good north star | Good IA checklist | Keep concepts in `docs/design/assets/` with README disclaimer | + +--- + +## P0 fixes before using GUI in figures + +1. **Scenario:** render preset path on map (`0 points · figure8` → actual polyline). +2. **Run:** ensure vehicle mesh visible after Play; capture `06-run-sim-*.png` in review bundle. +3. **Docs:** caption all concept PNGs as **“target mockup — not pixel spec”** (README already says this; enforce in mkdocs/thesis). + +--- + +## Automation + +```bash +python3 tools/gui_v3_capture.py # screenshots +python3 tools/gui_v3_eval_bundle.py # + API/E2E/parity +``` + +--- + +*Reviewer: automated capture + manual compare to `docs/design/assets/*.png`.* diff --git a/docs/evidence/review/gui-captures/01-scenario-l4.png b/docs/evidence/review/gui-captures/01-scenario-l4.png new file mode 100644 index 00000000..a1ec54e9 Binary files /dev/null and b/docs/evidence/review/gui-captures/01-scenario-l4.png differ diff --git a/docs/evidence/review/gui-captures/01-scenario.png b/docs/evidence/review/gui-captures/01-scenario.png new file mode 100644 index 00000000..6349f267 Binary files /dev/null and b/docs/evidence/review/gui-captures/01-scenario.png differ diff --git a/docs/evidence/review/gui-captures/02-build-default.png b/docs/evidence/review/gui-captures/02-build-default.png new file mode 100644 index 00000000..acae4d93 Binary files /dev/null and b/docs/evidence/review/gui-captures/02-build-default.png differ diff --git a/docs/evidence/review/gui-captures/03-build-l4-hardpoint.png b/docs/evidence/review/gui-captures/03-build-l4-hardpoint.png new file mode 100644 index 00000000..050e9ba6 Binary files /dev/null and b/docs/evidence/review/gui-captures/03-build-l4-hardpoint.png differ diff --git a/docs/evidence/review/gui-captures/04-analyze-idle.png b/docs/evidence/review/gui-captures/04-analyze-idle.png new file mode 100644 index 00000000..e9255164 Binary files /dev/null and b/docs/evidence/review/gui-captures/04-analyze-idle.png differ diff --git a/docs/evidence/review/gui-captures/05-analyze-results.png b/docs/evidence/review/gui-captures/05-analyze-results.png new file mode 100644 index 00000000..998709ba Binary files /dev/null and b/docs/evidence/review/gui-captures/05-analyze-results.png differ diff --git a/docs/evidence/review/gui-captures/06-run-idle.png b/docs/evidence/review/gui-captures/06-run-idle.png new file mode 100644 index 00000000..f0dd4f16 Binary files /dev/null and b/docs/evidence/review/gui-captures/06-run-idle.png differ diff --git a/docs/evidence/review/gui-captures/06-run-sim-t3.png b/docs/evidence/review/gui-captures/06-run-sim-t3.png new file mode 100644 index 00000000..fc77f822 Binary files /dev/null and b/docs/evidence/review/gui-captures/06-run-sim-t3.png differ diff --git a/docs/evidence/review/gui-captures/07-run-sim-t8.png b/docs/evidence/review/gui-captures/07-run-sim-t8.png new file mode 100644 index 00000000..75907f8b Binary files /dev/null and b/docs/evidence/review/gui-captures/07-run-sim-t8.png differ diff --git a/docs/index.md b/docs/index.md index 78bc2580..692220ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,11 +12,21 @@ single C++17 + Python ABI. ## Status -- **v0.5**: terrain + Ld5 stunt driving; parts catalog + scene runtime (`--scene=`), GUI catalog API. +- **Release line**: v0.5 terrain + Ld5 stunt driving; parts catalog + scene runtime + (`--scene=`), GUI catalog API. - **Physics**: LuGre tire default on (`lugre.enabled`); opt-in **MF2002 `.tir` backend** + **belt transient** (L1–L5); opt-in **drivetrain v2** (`powertrain:` — 2D engine map + gearbox + shift policy); kinematic fallback via `tire.kinematic_fallback` or `--no-lugre`. -- **Tests**: **328 / 328 ctest** (2026-06-10, `main`). +- **Trace & visualization (in integration)**: opt-in `.vdtrace` recording and headless BEV rendering; + single-run playback, multi-run comparison, and separate **waypoint**, **target**, and + **prediction** overlays. +- **Render presets (in integration)**: declarative panel and BEV-layer selection, + including the built-in `overview` layout. +- **C2 road contact (in integration)**: L3/L4 consume the full 3-D surface normal, + including grade/bank gravity and normal-load response. The road-bundle provider + remains pending. +- **Tests**: the current canonical result, configuration, toolchain, exclusions, commit, + and measurement date live only in [Validation](VALIDATION.md). - **Roadmap**: [Product roadmap](ROADMAP.md) — shipped vs planned by subsystem. - **User guide**: [Catalog & physics options](CATALOG_AND_PHYSICS.md). - **10 CLI demo binaries**, **Python bindings**, **CARLA-ready raycast ABI**, diff --git a/docs/validation/D0_FLAT_CONTACT_BASELINE.md b/docs/validation/D0_FLAT_CONTACT_BASELINE.md new file mode 100644 index 00000000..1305fe54 --- /dev/null +++ b/docs/validation/D0_FLAT_CONTACT_BASELINE.md @@ -0,0 +1,76 @@ +# [260901]_[Validation]_[D0FlatContactBaseline] + +## Purpose + +Freeze the current L3 and L4 flat-road responses before D1 contact-frame work. + +- Contact contract: every wheel has `normal=(0,0,1)`, `road_dz=0`, and `is_valid=true`. +- Scope: regression evidence only; this gate changes no core behavior and does not implement D1. +- Failure rule: any value outside the channel-specific tolerance blocks D1 work until reviewed. + +## Representative scenario + +- L3 plant: `create_fourteen_dof()` with default vehicle and tire parameters. +- L4 plant: `create_fourteen_dof_kinematic()` with the shipped + `mp_front_sedan.yaml` topology attached through `attach_topology_front()`. +- Initial speed: 15 m/s; wheel angular speed initialized to pure rolling. +- Duration: 2.0 s at 0.005 s per step. +- Commands: + - 0.0--0.5 s: throttle 0.20, steer 0. + - 0.5--1.5 s: throttle 0.15, steer 0.06 rad. + - 1.5--2.0 s: brake 0.25, steer 0.02 rad. +- Checkpoints: steps 0, 1, 20, 100, 200, 300, and 400. +- Raw channels: pose, body velocity, angular velocity, wheel spin, suspension travel/rate, rack state, per-wheel body force, Fz, slip, friction, roll, pitch, acceleration, and rack torque. + +## Frozen baseline + +- L3 fixture: `tests/fixtures/d0_flat_contact_l3_baseline.csv`. +- L4 fixture: `tests/fixtures/d0_flat_contact_l4_baseline.csv`. +- Encoding: decimal IEEE-754 doubles written with 17 significant digits for exact binary round-trip. +- L3 SHA-256: `bc81114b12f57fcad6d53ba997765e06853d505d7eaad32ff33211ff9d6cef53`. +- L4 SHA-256: `d4336ab41090777c19690a7d226ec608333e14d55df59499bcc868a11a92dba0`. +- Rows per fixture: 7 checkpoints plus one header row. + +## Equivalence tolerance + +- State/output absolute tolerance: `1e-12` in each channel's SI unit. +- Force absolute tolerance: `1e-9 N`. +- Rationale: + - The captured baseline and an immediate repeat are bit-identical, so the expected difference is zero. + - The 17-digit CSV round-trip is also exact for the captured values. + - `1e-12` preserves an identity-level state/output gate while allowing harmless compiler operation-order noise above machine epsilon. + - `1e-9 N` is approximately `2e-13` relative to a representative kilonewton wheel load and permits only floating-point algebra noise, not a physical force change. + +## Measured result (2026-09-01) + +- L3 repeat-run bit-identical: `true`. +- L3 repeat/fixture state-output/fixture force maximum absolute differences: + `0 / 0 / 0 N`. +- L4 repeat-run bit-identical: `true`. +- L4 repeat/fixture state-output/fixture force maximum absolute differences: + `0 / 0 / 0 N`. +- The same tolerances remain justified for both levels because their measured + repeated and serialized results are exact; no tolerance widening was needed. +- Fresh full suite with Node v20.20.2: `467/467` passed in 112.30 s. + +## Reproduction + +Run the committed gate normally: + +```bash +cmake -S . -B build +cmake --build build --target vdsim_integration_tests +ctest --test-dir build -R D0FlatContactBaseline --output-on-failure +``` + +Baseline regeneration is intentionally explicit and requires physics review before committing: + +```bash +VDSIM_D0_CAPTURE_L3=/tmp/d0_flat_contact_l3_baseline.csv \ + build/bin/vdsim_integration_tests \ + --gtest_filter=D0FlatContactBaseline.RepresentativeScenarioMatchesFrozenFixture + +VDSIM_D0_CAPTURE_L4=/tmp/d0_flat_contact_l4_baseline.csv \ + build/bin/vdsim_integration_tests \ + --gtest_filter=D0FlatContactBaseline.RepresentativeScenarioMatchesFrozenFixture +``` diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index ce6586b1..20c61edd 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -53,6 +53,13 @@ target_link_libraries(vdsim_module_check PRIVATE vdsim::core) set_target_properties(vdsim_module_check PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) +add_executable(vdsim_bench_levels bench_dynamics_levels.cpp) +target_compile_definitions(vdsim_bench_levels PRIVATE + VDSIM_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +target_link_libraries(vdsim_bench_levels PRIVATE vdsim::core) +set_target_properties(vdsim_bench_levels PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) + # Sample user subsystem-module built as a runtime-loadable plugin (.so) — used by the # module-plugin tests and as a worked example of the VDSIM_REGISTER_*_MODULE ABI. add_library(custom_brake_module MODULE modules/custom_brake.cpp) diff --git a/examples/bench_dynamics_levels.cpp b/examples/bench_dynamics_levels.cpp new file mode 100644 index 00000000..34b59564 --- /dev/null +++ b/examples/bench_dynamics_levels.cpp @@ -0,0 +1,129 @@ +// Benchmark one dynamics level via SimSession::tick (ioniq5_awd, dt, N steps). +// Used when a level is not exposed in pybind (L5) or for cross-checks. +// +// Usage: vdsim_bench_levels --level=L5 [--dt=5e-4] [--steps=500] [--warmup=50] +// Prints one line: ms_per_step (float). +#include "vdsim/coordinate.hpp" +#include "vdsim/interfaces.hpp" +#include "vdsim/params.hpp" +#include "vdsim/sim_session.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + +#ifndef VDSIM_SOURCE_DIR +#define VDSIM_SOURCE_DIR "." +#endif + +const char* kRepo = VDSIM_SOURCE_DIR; + +double opt_d(int argc, char** argv, const char* key, double def) { + const size_t n = std::strlen(key); + for (int i = 1; i < argc; ++i) { + if (std::strncmp(argv[i], key, n) == 0) + return std::atof(argv[i] + n); + } + return def; +} + +std::string opt_s(int argc, char** argv, const char* key, const std::string& def) { + const size_t n = std::strlen(key); + for (int i = 1; i < argc; ++i) { + if (std::strncmp(argv[i], key, n) == 0) + return std::string(argv[i] + n); + } + return def; +} + +vdsim::VehicleParams ioniq5_vp() { + return vdsim::VehicleParams::from_yaml( + std::string(kRepo) + "/configs/vehicles/ioniq5_awd.yaml"); +} + +vdsim::TireParams ioniq5_tp() { + vdsim::TireParams tp = vdsim::TireParams::from_yaml( + std::string(kRepo) + "/configs/parts/tire/ioniq5_pac2002.yaml"); + tp.tir_path = std::string(kRepo) + "/configs/parts/tire/ioniq5_pac2002.tir"; + tp.lugre.enabled = false; + return tp; +} + +std::unique_ptr make_dyn(const std::string& level) { + if (level == "K" || level == "L0") return vdsim::create_kinematic(); + if (level == "L1") return vdsim::create_bicycle(); + if (level == "L3") return vdsim::create_fourteen_dof(); + if (level == "L4") return vdsim::create_fourteen_dof_kinematic(); + if (level == "L5") return vdsim::create_stunt_dof(); + return vdsim::create_seven_dof(); +} + +vdsim::State init_state(const vdsim::VehicleParams& vp, const vdsim::TireParams& tp, + const std::string& level, double v) { + vdsim::State s; + s.position = vdsim::Vec3(0.0, 0.0, 0.0); + if (level == "L5") s.position.z() = vp.cg_height; + s.orientation = vdsim::quat_from_euler(vdsim::Euler{0.0, 0.0, 0.0}); + s.velocity = vdsim::Vec3(v, 0.0, 0.0); + s.wheel_spin = vdsim::free_roll_wheel_spin(vp, tp, v); + return s; +} + +double bench(const std::string& level, double dt, int steps, int warmup, double v0) { + const auto vp = ioniq5_vp(); + const auto tp = ioniq5_tp(); + vdsim::SolverParams sp; + if (level == "L5") sp.stunt_physics = true; + + vdsim::SimConfig cfg; + cfg.nominal_dt = dt; + vdsim::SimSession sess(make_dyn(level), + vdsim::create_flat_ground(0.0, tp.mu_nominal), + vp, tp, sp, cfg); + sess.reset(init_state(vp, tp, level, v0)); + + vdsim::CmdL4 cmd; + cmd.steer_angle_wheel = 0.02; + for (int i = 0; i < warmup; ++i) { + sess.set_input(vdsim::ControlInput{cmd}); + sess.tick(dt); + } + + const auto t0 = std::chrono::steady_clock::now(); + for (int i = 0; i < steps; ++i) { + sess.set_input(vdsim::ControlInput{cmd}); + sess.tick(dt); + } + const double ms = std::chrono::duration( + std::chrono::steady_clock::now() - t0).count(); + return ms / static_cast(steps); +} + +} // namespace + +int main(int argc, char** argv) { + const std::string level = opt_s(argc, argv, "--level=", "L5"); + const double dt = opt_d(argc, argv, "--dt=", 5e-4); + const int steps = static_cast(opt_d(argc, argv, "--steps=", 500)); + const int warmup = static_cast(opt_d(argc, argv, "--warmup=", 50)); + const double v0 = opt_d(argc, argv, "--v0=", 20.0); + + if (steps <= 0 || warmup < 0 || !(dt > 0.0)) { + std::fprintf(stderr, "invalid bench args\n"); + return 2; + } + + try { + const double ms = bench(level, dt, steps, warmup, v0); + std::printf("%.4f\n", ms); + return 0; + } catch (const std::exception& e) { + std::fprintf(stderr, "bench %s failed: %s\n", level.c_str(), e.what()); + return 1; + } +} diff --git a/examples/demo_compare_runs.py b/examples/demo_compare_runs.py new file mode 100755 index 00000000..ccb8f7a6 --- /dev/null +++ b/examples/demo_compare_runs.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Headless multi-run demo — same manoeuvre at three grip levels, overlaid. + + pip install "./vdsim-*.whl[plot]" + python examples/demo_compare_runs.py + +Same split as ``demo_grip_loss.py``, one step further: each run is recorded to +its own ``.vdtrace``, and the picture is made from the files alone. The three +runs share the controller and the command sequence, so every difference in the +animation is the tyres losing grip — which is exactly the claim a comparison +picture has to support. + +Writes docs/assets/demo_compare_runs.gif (or --out) and the traces beside it. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# The in-tree copies win over anything installed (see demo_grip_loss.py). +_repo = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(_repo / "python"), str(_repo / "build" / "python")] + +from vdsim_plant import VDSimPlant # noqa: E402 (path set above) +import vdsim_render # noqa: E402 +import vdsim_trace # noqa: E402 + +V0 = 16.7 +DT = 0.05 +PATCH = (60.0, 160.0, 0.5) +N_STEPS = 120 +#: (label, base friction) per run — one dry, one damp, one wet. +RUNS = (("mu 0.90 (dry)", 0.90), + ("mu 0.65 (damp)", 0.65), + ("mu 0.45 (wet)", 0.45)) +CAPTION = ("Same controller, same commands, three friction levels — overlaid " + "from three .vdtrace files. Divergence is grip, not tuning.") + + +def record(trace_path: Path, base_mu: float, run_id: str, + n_steps: int = N_STEPS) -> Path: + """Run the identical manoeuvre at one friction level. + + :param trace_path: output ``.vdtrace``. + :param base_mu: friction outside the low-mu patch. + :param run_id: manifest ``repro.run_id``; the renderer uses it as the label. + :param n_steps: control steps to simulate. + :returns: the written trace path. + """ + plant = VDSimPlant( + config="ioniq5_awd.yaml", + friction_map=[PATCH], + base_mu=base_mu, + control_dt=DT, + substep_dt=5e-4, + ) + plant.reset([0.0, 0.0, 0.0, V0, 0.0, 0.0]) + plant.enable_trace(trace_path, seed=0, run_id=run_id, + producer={"name": "demo_compare_runs.py", "version": "0.1"}, + tags={"scenario": "grip_sweep", "base_mu": base_mu}) + for k in range(n_steps): + # Identical command sequence in every run: the only independent + # variable is base_mu. + plant.step([0.0, 0.0] if k == 0 else [0.12, -12000.0]) + path = plant.finalize_trace() + + x0, x1, mu_lo = PATCH + vdsim_trace.attach_overlay(path, { + "kind": "region", "name": "mu_patch", "mu": mu_lo, + "polygon": [[x0, -30.0], [x1, -30.0], [x1, 30.0], [x0, 30.0]]}) + vdsim_trace.attach_overlay(path, { + "kind": "path2d", "name": "intended_path", + "xy": [[float(i) * 4.0, 0.0] for i in range(60)]}) + return path + + +def _default_out() -> Path: + repo = Path(__file__).resolve().parents[1] + assets = repo / "docs" / "assets" + if assets.is_dir(): + return assets / "demo_compare_runs.gif" + return Path.cwd() / "demo_compare_runs.gif" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--out", type=Path, default=None, help="output GIF path") + ap.add_argument("--fps", type=int, default=12) + ap.add_argument("--alpha", type=float, default=vdsim_render.BODY_ALPHA, + help="body fill alpha (overlapping vehicles)") + ap.add_argument("--keep-traces", action="store_true", + help="keep the .vdtrace files next to the GIF") + args = ap.parse_args() + + out = args.out or _default_out() + traces = [] + for label, base_mu in RUNS: + path = out.with_name("%s_mu%02d.vdtrace" + % (out.stem, int(round(base_mu * 100)))) + traces.append(record(path, base_mu, run_id=label)) + print("recorded %s" % (path,)) + + res = vdsim_render.render_multi( + traces, out=out, fps=args.fps, alpha=args.alpha, title=CAPTION) + # The runs start together; if they never separate, the demo is not showing + # what it claims and the picture would be a straight-line non-result. + if res["max_spread_m"] < 0.5: + raise SystemExit("runs stayed within %.2f m — grip sweep had no effect " + "(scenario regression)" % res["max_spread_m"]) + if not args.keep_traces: + for path in traces: + path.unlink() + + +if __name__ == "__main__": + main() diff --git a/examples/demo_grip_loss.py b/examples/demo_grip_loss.py index be9904af..97987119 100644 --- a/examples/demo_grip_loss.py +++ b/examples/demo_grip_loss.py @@ -1,44 +1,57 @@ #!/usr/bin/env python3 -"""Headless grip-loss demo: closed-loop plant, low-mu patch, GIF output. +"""Headless grip-loss demo — records a trace, then renders it. pip install "./vdsim-*.whl[plot]" python examples/demo_grip_loss.py -Writes demo_grip_loss.gif (or --out path). Pillow writer only (no ffmpeg). +The demo is deliberately split in two halves that never talk to each other +except through one file: + +1. run the closed-loop plant with recording on, producing ``demo_grip_loss.vdtrace`` +2. render that file + +Nothing in step 2 knows the scenario. The low-mu patch and the reference path +reach the picture as overlays (contract §4), not as renderer knowledge, which +is what makes the demo a self-check of the trace contract rather than a +demonstration that happens to use it. + +Writes docs/assets/demo_grip_loss.gif (or --out) and the trace beside it. """ from __future__ import annotations import argparse -import math +import sys from pathlib import Path -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.animation import FuncAnimation, PillowWriter -from matplotlib.gridspec import GridSpec -from matplotlib.patches import Rectangle +# The in-tree copies win over anything installed. An older site-packages +# vdsim_plant has no trace hook, and vdsim_render/vdsim_trace live only here, +# so a partial install would otherwise half-resolve and fail at the second +# import rather than at the first. +_repo = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(_repo / "python"), str(_repo / "build" / "python")] -try: - from vdsim_plant import VDSimPlant -except ImportError: - import sys - - _repo = Path(__file__).resolve().parents[1] - sys.path[:0] = [str(_repo / "python"), str(_repo / "build" / "python")] - from vdsim_plant import VDSimPlant +from vdsim_plant import VDSimPlant # noqa: E402 (path set above) +import vdsim_render # noqa: E402 +import vdsim_trace # noqa: E402 V0 = 16.7 DT = 0.05 PATCH = (60.0, 160.0, 0.5) BASE_MU = 0.9 -WHEELS = ("FL", "FR", "RL", "RR") +N_STEPS = 120 CAPTION = ( "Deterministic VDSim plant: controller hits an unseen low-μ patch, " - "tyres saturate past peak (drift>1) — real grip loss, not a soft-clamp." + "tyres saturate (utilization -> 1) — real grip loss, not a soft-clamp." ) -def _run_sim(n_steps: int = 120): +def record(trace_path: Path, n_steps: int = N_STEPS) -> Path: + """Run the plant with tracing on and attach the scenario overlays. + + :param trace_path: output ``.vdtrace``. + :param n_steps: control steps to simulate. + :returns: the written trace path. + """ plant = VDSimPlant( config="ioniq5_awd.yaml", friction_map=[PATCH], @@ -47,17 +60,32 @@ def _run_sim(n_steps: int = 120): substep_dt=5e-4, ) plant.reset([0.0, 0.0, 0.0, V0, 0.0, 0.0]) - hist = [] - obs = None + plant.enable_trace(trace_path, seed=0, run_id="demo_grip_loss", + producer={"name": "demo_grip_loss.py", "version": "0.1"}, + tags={"scenario": "grip_loss"}) + peak_drift = 0.0 for k in range(n_steps): - if k == 0: - delta, fx = 0.0, 0.0 - else: - delta = 0.12 - fx = -12000.0 - obs = plant.step([delta, fx]) - hist.append(obs) - return hist + u = [0.0, 0.0] if k == 0 else [0.12, -12000.0] + obs = plant.step(u) + for w in obs["wheel"]: + peak_drift = max(peak_drift, abs(w["alpha"]) / max(w["alpha_peak"], 1e-4)) + path = plant.finalize_trace() + if peak_drift <= 1.0: + raise SystemExit( + "expected slip past peak on at least one wheel, got %.2f " + "(scenario regression)" % peak_drift) + + # Scenario knowledge enters as overlays. VDSim stores them and never reads + # them; the renderer draws them by `kind`, not by name. + x0, x1, mu_lo = PATCH + vdsim_trace.attach_overlay(path, { + "kind": "region", "name": "mu_patch", "mu": mu_lo, + "polygon": [[x0, -30.0], [x1, -30.0], [x1, 30.0], [x0, 30.0]]}) + vdsim_trace.attach_overlay(path, { + "kind": "path2d", "name": "intended_path", + "xy": [[float(i) * 4.0, 0.0] for i in range(60)]}) + print("recorded %s (peak slip/peak-slip ratio %.2f)" % (path, peak_drift)) + return path def _default_out() -> Path: @@ -68,96 +96,22 @@ def _default_out() -> Path: return Path.cwd() / "demo_grip_loss.gif" -def _make_gif(hist: list, out: Path, fps: int = 12): - xs = [h["X"] for h in hist] - ys = [h["Y"] for h in hist] - x0, x1, mu_lo = PATCH - - fig = plt.figure(figsize=(9.0, 5.0), dpi=100) - fig.suptitle(CAPTION, fontsize=9.0, y=0.98) - gs = GridSpec(2, 2, figure=fig, width_ratios=[1.2, 1.0], hspace=0.35, wspace=0.28) - - ax_path = fig.add_subplot(gs[:, 0]) - ax_alpha = fig.add_subplot(gs[0, 1]) - ax_fc = fig.add_subplot(gs[1, 1]) - - pad = 15.0 - ylo, yhi = min(ys) - 8, max(ys) + 8 - ax_path.set_xlim(min(xs) - pad, max(xs) + pad) - ax_path.set_ylim(ylo, yhi) - ax_path.set_aspect("equal", adjustable="box") - ax_path.set_xlabel("X [m]") - ax_path.set_ylabel("Y [m]") - ax_path.set_title("path + μ patch") - ax_path.add_patch(Rectangle( - (x0, ylo), x1 - x0, yhi - ylo, - facecolor="#c44e52", alpha=0.18, edgecolor="#c44e52", linewidth=1.0, zorder=0, - )) - ax_path.text(x0 + 4, yhi - 1.5, f"μ≈{mu_lo:.1f}", color="#8b1a1a", fontsize=8) - (trail,) = ax_path.plot([], [], "k-", lw=1.0, alpha=0.5) - (car,) = ax_path.plot([], [], "o", color="#1f77b4", ms=8, zorder=5) - - x_ax = np.arange(4) - bars = ax_alpha.bar(x_ax, [0.0] * 4, color="#4c72b0", width=0.65) - peak_lines = ax_alpha.axhline(1.0, color="#555", ls="--", lw=0.8, label="|α|/α_peak=1") - ax_alpha.set_xticks(x_ax) - ax_alpha.set_xticklabels(WHEELS) - ax_alpha.set_ylabel("|α| / α_peak") - ax_alpha.set_title("slip past peak (drift>1)") - ax_alpha.set_ylim(0.0, max(2.5, 1.0)) - ax_alpha.legend(loc="upper right", fontsize=7) - - ax_fc.set_xlim(-1.1, 1.1) - ax_fc.set_ylim(-1.1, 1.1) - ax_fc.set_aspect("equal") - ax_fc.set_xlabel("Fx / (μ·Fz)") - ax_fc.set_ylabel("Fy / (μ·Fz)") - ax_fc.set_title("FL friction usage (normalised)") - theta = np.linspace(0, 2 * np.pi, 128) - (circle,) = ax_fc.plot(np.cos(theta), np.sin(theta), "k--", lw=0.8, alpha=0.6) - (force_pt,) = ax_fc.plot([], [], "o", color="#d62728", ms=9) - - def _update(i): - h = hist[i] - trail.set_data(xs[: i + 1], ys[: i + 1]) - car.set_data([h["X"]], [h["Y"]]) - ymax = 1.0 - for j, w in enumerate(h["wheel"]): - ap = max(float(w["alpha_peak"]), 1e-4) - ratio = abs(float(w["alpha"])) / ap - ymax = max(ymax, ratio * 1.05) - bars[j].set_height(ratio) - bars[j].set_color("#d62728" if ratio > 1.0 else "#4c72b0") - ax_alpha.set_ylim(0.0, min(ymax, 3.0)) - w0 = h["wheel"][0] - cap = max(float(w0["mu"]) * float(w0["Fz"]), 1.0) - fx_n = float(w0["Fx"]) / cap - fy_n = float(w0["Fy"]) / cap - force_pt.set_data([fx_n], [fy_n]) - return trail, car, *bars, force_pt - - anim = FuncAnimation(fig, _update, frames=len(hist), interval=1000 // fps, - blit=False, repeat=True) - out.parent.mkdir(parents=True, exist_ok=True) - anim.save(str(out), writer=PillowWriter(fps=fps), savefig_kwargs={"transparent": False}) - plt.close(fig) - print(f"wrote {out} ({len(hist)} frames, {len(hist) / fps:.1f}s loop)") - - def main(): - ap = argparse.ArgumentParser(description=__doc__) + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--out", type=Path, default=None, help="output GIF path") + ap.add_argument("--trace", type=Path, default=None, help="output .vdtrace path") ap.add_argument("--fps", type=int, default=12) + ap.add_argument("--keep-trace", action="store_true", + help="keep the .vdtrace next to the GIF") args = ap.parse_args() + out = args.out or _default_out() - hist = _run_sim() - drift_hit = any( - abs(w["alpha"]) / max(w["alpha_peak"], 1e-4) > 1.0 - for h in hist for w in h["wheel"] - ) - if not drift_hit: - raise SystemExit("expected drift>1 on at least one step (scenario regression)") - _make_gif(hist, out, fps=args.fps) + trace_path = args.trace or out.with_suffix(".vdtrace") + record(trace_path) + vdsim_render.render(trace_path, out=out, fps=args.fps, stride=1, + title=CAPTION) + if not args.keep_trace and args.trace is None: + trace_path.unlink() if __name__ == "__main__": diff --git a/gui/api/routes.py b/gui/api/routes.py index 1794cd4d..7b4b3a0e 100644 --- a/gui/api/routes.py +++ b/gui/api/routes.py @@ -183,12 +183,55 @@ def _get_assembly(h, qs, ctx): } +def _v3_dist_root(ctx): + return (ctx.here / "v3" / "dist").resolve() + + +def _serve_v3_file(h, ctx, rel, content_type): + root = _v3_dist_root(ctx) + fp = (root / rel).resolve() + if not str(fp).startswith(str(root)) or not fp.is_file(): + return False + bytes_response(h, fp.read_bytes(), content_type, + extra_headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) + return True + + +def _handle_v3_static(h, route, ctx): + if route in ("/v3", "/v3/"): + return _serve_v3_file(h, ctx, "index.html", "text/html; charset=utf-8") + if route == "/v3/index.html": + return _serve_v3_file(h, ctx, "index.html", "text/html; charset=utf-8") + if not route.startswith("/v3/"): + return False + rel = route[len("/v3/"):].split("?")[0] + if not rel: + return _serve_v3_file(h, ctx, "index.html", "text/html; charset=utf-8") + if rel.endswith(".js"): + ct = "application/javascript; charset=utf-8" + elif rel.endswith(".css"): + ct = "text/css; charset=utf-8" + elif rel.endswith(".svg"): + ct = "image/svg+xml" + elif rel.endswith(".map"): + ct = "application/json" + else: + ct = "application/octet-stream" + if _serve_v3_file(h, ctx, rel, ct): + return True + h.send_response(404) + h.end_headers() + return True + + def handle_get(h, route, qs, ctx): if route in ("/", "/app", "/app.html"): html = (ctx.here / "app.html").read_bytes() bytes_response(h, html, "text/html; charset=utf-8", extra_headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) return True + if _handle_v3_static(h, route, ctx): + return True if route == "/core.js": data = (ctx.here / "core.js").read_bytes() bytes_response(h, data, "application/javascript; charset=utf-8", @@ -321,6 +364,16 @@ def handle_get(h, route, qs, ctx): except ValueError as e: json_response(h, {"ok": False, "error": str(e)}, 400) return True + if route == "/api/suspension/kin": + from runner.suspension import load_suspension_kin + name = (qs.get("name") or [""])[0] + part_id = (qs.get("part_id") or [""])[0] + try: + json_response(h, load_suspension_kin( + name=name or None, part_id=part_id or None)) + except ValueError as e: + json_response(h, {"ok": False, "error": str(e)}, 400) + return True if route == "/api/runconfig/draft.yaml": import yaml doc = ctx.runner.export_run_config() @@ -522,6 +575,25 @@ def handle_post(h, path, body, ctx): except Exception as e: json_response(h, {"ok": False, "error": str(e)}, 400) return True + if path == "/api/suspension/kin/preview": + from runner.suspension import preview_suspension_kin + try: + json_response(h, preview_suspension_kin(body.get("doc") or {})) + except ValueError as e: + json_response(h, {"ok": False, "error": str(e)}, 400) + return True + if path == "/api/suspension/kin/save": + try: + out = r.save_catalog_kin( + body.get("doc") or {}, + body.get("stem", "kin_edit"), + body.get("label", "Kin edit"), + base_part_id=body.get("base_part_id"), + ) + json_response(h, {"ok": True, **out}) + except Exception as e: + json_response(h, {"ok": False, "error": str(e)}, 400) + return True if path == "/api/catalog/parts/delete": try: out = r.delete_catalog_part(body.get("part_id", "")) @@ -610,6 +682,16 @@ def handle_post(h, path, body, ctx): json_response(h, {"ok": True, "setup": r.get_setup(include_geom=False, include_scenarios=False)}) return True + if path == "/api/setup/template": + key = str(body.get("template") or body.get("name") or "") + try: + r.apply_scenario_template(key) + except ValueError as e: + json_response(h, {"ok": False, "error": str(e)}, code=400) + return True + json_response(h, {"ok": True, "template": key, + "setup": r.get_setup(include_geom=False, include_scenarios=False)}) + return True if path == "/api/run/start": cfg = body.get("config") if cfg: diff --git a/gui/runner/autopilot.py b/gui/runner/autopilot.py index b73e9ba9..7b383474 100644 --- a/gui/runner/autopilot.py +++ b/gui/runner/autopilot.py @@ -37,6 +37,14 @@ def steer(self, x, y, yaw, vx, wb, prev_idx): return max(-0.6, min(0.6, math.atan(2.0 * dy / l2 * wb))), near +def circle_pts(cx=0.0, cy=0.0, R=40.0, n=64): + pts = [] + for i in range(n): + t = 2 * math.pi * i / n + pts.append((cx + R * math.cos(t), cy + R * math.sin(t))) + return pts + + def fig8_pts(cx=0.0, cy=0.0, R=20.0, n=80): pts = [] for i in range(n): diff --git a/gui/runner/catalog_api.py b/gui/runner/catalog_api.py index ad21ae0e..2a1cc064 100644 --- a/gui/runner/catalog_api.py +++ b/gui/runner/catalog_api.py @@ -174,6 +174,27 @@ def catalog_part_import_kin(text: str, stem: str, label: str) -> dict: return save_user_kin_part(REPO, doc, kin) +def catalog_kin_save( + doc: dict, + stem: str, + label: str, + *, + base_part_id: Optional[str] = None, +) -> dict: + import yaml + from catalog.part_store import kin_yaml_to_susp_part, save_user_kin_part + from runner.config import REPO + + text = yaml.safe_dump(doc, sort_keys=False) + clone = catalog_resolver().load_part(base_part_id) if base_part_id else None + part_doc, kin = kin_yaml_to_susp_part(text, stem, label, clone_from=clone) + if base_part_id and "gui_custom" in base_part_id: + part_doc["id"] = base_part_id + part_doc["label"] = str(label) + saved = save_user_kin_part(REPO, part_doc, kin) + return {"part_id": saved["part_id"], "label": saved.get("label", label), "part": saved} + + def catalog_part_delete(part_id: str) -> dict: from catalog.part_store import delete_user_part from runner.config import REPO diff --git a/gui/runner/draft.py b/gui/runner/draft.py index a6406366..c810fb28 100644 --- a/gui/runner/draft.py +++ b/gui/runner/draft.py @@ -530,3 +530,40 @@ def import_simconfig(self, data): self.import_scene_v3(data) self._rebuild_if_running() return self.config() + + def apply_scenario_template(self, key): + allowed = ("empty", "figure8", "straight", "skidpad") + if key not in allowed: + raise ValueError(f"unknown template '{key}'") + with self.lock: + if self.cfg["running"]: + raise ValueError("stop simulation before applying a template") + self.fleet_overrides = {} + row = self._default_fleet_spec(0) + strip_fleet_susp_if_not_l3(row) + self.fleet_spec = [row] + self.live_vid = 0 + self._ensure_ports() + self._sync_live_from_fleet() + self.cfg["road_mu"] = 1.0 + self.cfg["road_grade"] = 0.0 + self.cfg["road_bank"] = 0.0 + self.cfg["road_mu_right"] = -1.0 + self.cfg["road_boundary"] = 0.0 + self.cfg["road_rough_amp"] = 0.0 + self.cfg["road_rough_wl"] = 4.0 + if key == "empty": + self.path = WaypointPath([(0.0, 0.0), (30.0, 0.0)]) + self.path_preset = "custom" + self.cfg["v_target"] = 10.0 + elif key == "figure8": + self.set_path_preset("figure8") + self.cfg["v_target"] = 10.0 + elif key == "straight": + self.set_path_preset("straight") + self.cfg["v_target"] = 15.0 + else: + self.set_path_preset("skidpad") + self.cfg["v_target"] = 12.0 + if not self.cfg["running"]: + self.latest = self._setup_snapshot() diff --git a/gui/runner/suspension.py b/gui/runner/suspension.py index d242fca9..3f5c5d26 100644 --- a/gui/runner/suspension.py +++ b/gui/runner/suspension.py @@ -1,3 +1,4 @@ +import tempfile from pathlib import Path import yaml @@ -213,19 +214,8 @@ def _kc_series(samples, y_attr, label, color): } -def suspension_kc_plots(name): - import vdsim - - stem = susp_stem_from_ref(name) - path = kin_yaml_path(stem) - if path is None or not path.is_file(): - raise ValueError(f"unknown suspension: {name}") - if not is_l3_kinematics_yaml(stem): - raise ValueError(f"suspension '{stem}' has no native kinematics YAML") - doc = yaml.safe_load(path.read_text()) or {} - typ = str(doc.get("type") or "unknown") - r = vdsim.run_kc_sweep(str(path)) - plots = [ +def _kc_plots_from_sweep(r): + return [ { "title": "Camber vs wheel travel", "xlabel": "wheel travel [mm]", @@ -275,4 +265,118 @@ def suspension_kc_plots(name): ], }, ] - return {"name": stem, "type": typ, "plots": plots} + + +def kin_geometry_flat(doc): + out = {} + + def walk(node, prefix=""): + if isinstance(node, dict): + for k, v in node.items(): + walk(v, f"{prefix}.{k}" if prefix else k) + elif isinstance(node, list) and len(node) == 3: + if all(isinstance(x, (int, float)) for x in node): + out[prefix] = [float(node[0]), float(node[1]), float(node[2])] + + walk(doc) + return out + + +def update_kin_path(doc, path, value): + parts = str(path).split(".") + node = doc + for p in parts[:-1]: + node = node[p] + node[parts[-1]] = [float(value[0]), float(value[1]), float(value[2])] + return doc + + +def resolve_kin_file_path(name=None, part_id=None): + from runner.catalog_bridge import catalog_resolver + + if part_id: + part = catalog_resolver().load_part(part_id) + rel = str((part.get("body") or {}).get("path") or "") + if not rel: + raise ValueError(f"part {part_id} has no kinematics path") + path = Path(rel) + if not path.is_absolute(): + path = (catalog_resolver().catalog_root / path).resolve() + if not path.is_file(): + raise ValueError(f"kinematics file not found: {path}") + return path, susp_stem_from_ref(part_id), part + stem = susp_stem_from_ref(name) + path = kin_yaml_path(stem) + if path is None or not path.is_file(): + raise ValueError(f"unknown suspension: {name}") + return path, stem, None + + +def _kc_plots_from_path(yaml_path): + import vdsim + + path = Path(yaml_path) + doc = yaml.safe_load(path.read_text()) or {} + if not doc.get("type"): + raise ValueError(f"{path.name} has no native kinematics type") + typ = str(doc.get("type") or "unknown") + r = vdsim.run_kc_sweep(str(path)) + return {"type": typ, "plots": _kc_plots_from_sweep(r)} + + +def load_suspension_kin(name=None, part_id=None): + path, stem, part = resolve_kin_file_path(name=name, part_id=part_id) + doc = yaml.safe_load(path.read_text()) or {} + if not doc.get("type"): + raise ValueError(f"'{stem}' is not L3-native kinematics") + links, pts = corner_kinematic_links(doc) + pid = part_id or (part["id"] if part else f"chassis.{stem}") + rel = str(path) + try: + rel = str(path.relative_to(REPO)) + except ValueError: + pass + readonly = "gui_custom" not in str(path) and "gui_custom" not in str(pid) + return { + "ok": True, + "name": stem, + "part_id": pid, + "type": str(doc.get("type") or "unknown"), + "doc": doc, + "geometry": kin_geometry_flat(doc), + "schematic": {"links": links, "points": pts}, + "path": rel, + "readonly": readonly, + } + + +def preview_suspension_kin(doc): + if not isinstance(doc, dict) or not doc.get("type"): + raise ValueError("kin doc needs top-level type") + links, pts = corner_kinematic_links(doc) + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.safe_dump(doc, f, sort_keys=False) + tmp = f.name + try: + kc = _kc_plots_from_path(tmp) + finally: + Path(tmp).unlink(missing_ok=True) + return { + "ok": True, + "type": kc["type"], + "geometry": kin_geometry_flat(doc), + "schematic": {"links": links, "points": pts}, + "plots": kc["plots"], + } + + +def suspension_kc_plots(name): + stem = susp_stem_from_ref(name) + path = kin_yaml_path(stem) + if path is None or not path.is_file(): + raise ValueError(f"unknown suspension: {name}") + if not is_l3_kinematics_yaml(stem): + raise ValueError(f"suspension '{stem}' has no native kinematics YAML") + out = _kc_plots_from_path(path) + out["name"] = stem + return out diff --git a/gui/server.py b/gui/server.py index 0d28db81..402bd802 100644 --- a/gui/server.py +++ b/gui/server.py @@ -134,6 +134,7 @@ def __init__(self): self.fleet_overrides = {} self.plant_error = None self._wait_since = None + self._sim_t0 = None self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.cosim = CosimBridge() # binary co-sim server (configured + launched here) self.rec_on = False # logging recorder @@ -441,6 +442,14 @@ def import_catalog_part_kin(self, text, stem, label): self.reload_catalog() return out + def save_catalog_kin(self, doc, stem, label, base_part_id=None): + from runner.catalog_api import catalog_kin_save + with self.lock: + out = catalog_kin_save( + doc, stem, label, base_part_id=base_part_id) + self.reload_catalog() + return out + def delete_catalog_part(self, part_id): from runner.catalog_api import catalog_part_delete with self.lock: @@ -575,6 +584,9 @@ def set_path_preset(self, name): self.path = FigureEight() elif name == "straight": self.path = WaypointPath([(-40.0, 0.0), (40.0, 0.0)]) + elif name == "skidpad": + from runner.autopilot import circle_pts + self.path = WaypointPath(circle_pts(0.0, 0.0, 40.0, 64)) def _default_fleet_spec(self, vid=0, offset_xy=(0.0, 0.0)): veh = str(self.cfg.get("vehicle", "sedan")) @@ -736,6 +748,13 @@ def _setup_snapshot(self): "grade": self.cfg["road_grade"], "bank": self.cfg["road_bank"], } + def _plant_sim_t(self, t_raw): + t = float(t_raw or 0.0) + with self.lock: + if self._sim_t0 is None: + self._sim_t0 = t + return max(0.0, t - self._sim_t0) + def control(self, action): start_req = False with self.lock: @@ -745,11 +764,13 @@ def control(self, action): self.plant_error = None self.cosim.stop() self.prev_idx = 0 + self._sim_t0 = None elif action == "stop": self.cfg["running"] = False self.cfg["paused"] = False self.plant_error = None self.cosim.stop() + self._sim_t0 = None elif action == "pause": if self.cfg["running"]: self.cfg["paused"] = True @@ -763,6 +784,7 @@ def control(self, action): self._prune_cosim_states() self.prev_idx = 0 self.plant_error = None + self._sim_t0 = None self.cfg["paused"] = False if not self.cfg.get("cosim_attach") and not self.cosim.available(): self.cfg["running"] = False @@ -996,6 +1018,7 @@ def _loop(self): with self.lock: self._wait_since = None self._run_since = None + self._sim_t0 = None snap = self._setup_snapshot() with self.lock: self.latest = snap @@ -1037,7 +1060,7 @@ def _loop(self): self._wait_since = None live_spec = next((f for f in self.fleet_spec if int(f["id"]) == self.live_vid), {}) live_lv = str(live_spec.get("level", self.cfg["level"])) - snap = {"t": cs["t"], "running": run, "paused": paused, "setup_mode": False, + snap = {"t": self._plant_sim_t(cs["t"]), "running": run, "paused": paused, "setup_mode": False, "driver": focus_driver, "x": cs["x"], "y": cs["y"], "z": cs["z"], "yaw": cs["yaw"], "roll": cs["roll"], "pitch": cs["pitch"], @@ -1090,7 +1113,7 @@ def _loop(self): else: hx, hy = self.cfg["init_x"], self.cfg["init_y"] hyaw, hvx, ht = self.cfg["init_yaw"], 0.0, 0.0 - snap = {"t": ht, "running": run, "paused": paused, "setup_mode": False, + snap = {"t": self._plant_sim_t(ht), "running": run, "paused": paused, "setup_mode": False, "driver": focus_driver, "x": hx, "y": hy, "z": 0.0, "yaw": hyaw, "roll": 0.0, "pitch": 0.0, diff --git a/gui/v3/.gitignore b/gui/v3/.gitignore new file mode 100644 index 00000000..e2c080b7 --- /dev/null +++ b/gui/v3/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +test-results/ +playwright-report/ +blob-report/ +playwright/.cache/ diff --git a/gui/v3/README.md b/gui/v3/README.md new file mode 100644 index 00000000..d0500bee --- /dev/null +++ b/gui/v3/README.md @@ -0,0 +1,64 @@ +# VDSim GUI v3 (Svelte 5 + Vite) + +Scenario-first web UI per [`docs/design/GUI_V3_ADR.md`](../../docs/design/GUI_V3_ADR.md). + +| Mode | Route | Status | +|------|-------|--------| +| **Scenario** | `#/scenario` (default) | **API + map** — fleet drag, path preset/**waypoint edit**, save/load | +| **Run** | `#/run` | **SSE + Three.js** chase/orbit/top, telemetry, stop/reset | +| **Build** | `#/build` | **catalog assembly** — slots, blueprint export/register, **parts library**, Δ stats, linkage SVG, **L4 hardpoint editor** | +| **Analyze** | `#/analyze` | **ISO compare** — fleet/catalog picks, maneuvers, yaw-rate chart, Δ% table, CSV | + +Concept art: [`docs/design/assets/`](../../docs/design/assets/). + +## Prerequisites + +- Node **≥ 20** +- Python GUI server on port **8095** (or adjust `vite.config.ts` proxy) + +## Develop + +```bash +# terminal 1 — API + SSE +python3 gui/server.py --port 8095 + +# terminal 2 — Vite HMR (proxies /api → 8095) +cd gui/v3 && npm install && npm run dev +# open http://localhost:5173/v3/ +``` + +## Production build (served by server.py) + +```bash +cd gui/v3 && npm run build +python3 gui/server.py --port 8095 +# open http://localhost:8095/v3/ +``` + +## Headless E2E (Playwright) + +```bash +cd gui/v3 && npm install && npm run test:e2e:install +python3 tests/scripts/test_gui_v3_e2e.py # starts server on free port + runs browser +# or: ctest -R gui_v3_e2e +``` + +`gui/v3/dist/` is gitignored — run `npm run build` after clone. + +## Layout + +``` +src/ + modes/scenario/ # P0 — scenario studio + modes/run/ # P1 — Three.js viewport (port from v1) + modes/build/ # parts + L4 hardpoint editor + modes/analyze/ + lib/ + api/ # setup.ts, client.ts, types.ts + map/ # viewport.ts — 2D scenario map + setupStore.svelte.ts + components/ + MapCanvas.svelte +``` + +v1 remains at `/app.html` until v3 parity ([`GUI_V3_REQUIREMENTS.md`](../../docs/design/GUI_V3_REQUIREMENTS.md) §8). diff --git a/gui/v3/e2e/capture-review.spec.ts b/gui/v3/e2e/capture-review.spec.ts new file mode 100644 index 00000000..2ab8dbfa --- /dev/null +++ b/gui/v3/e2e/capture-review.spec.ts @@ -0,0 +1,113 @@ +import { test, expect } from '@playwright/test' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const OUT = path.resolve(__dirname, '../../../docs/evidence/review/gui-captures') + +type Mode = 'scenario' | 'run' | 'build' | 'analyze' + +async function openMode(page: import('@playwright/test').Page, mode: Mode) { + const setupReady = page.waitForResponse( + (r) => r.url().includes('/api/setup') && r.request().method() === 'GET' && r.ok(), + { timeout: 25_000 }, + ) + await page.goto(`index.html?m=${mode}#/${mode}`) + await setupReady + await expect(page.getByText(`Mode: ${mode}`)).toBeVisible({ timeout: 20_000 }) + await page.waitForTimeout(400) +} + +test.describe('GUI capture review', () => { + test('capture user flow', async ({ page }) => { + test.setTimeout(120_000) + fs.mkdirSync(OUT, { recursive: true }) + + await openMode(page, 'scenario') + const scenarioSel = page.locator('aside select.wide').first() + if (await scenarioSel.count()) { + const fleetLoad = page.waitForResponse( + (r) => r.url().includes('/api/fleet') && r.request().method() === 'POST' && r.ok(), + { timeout: 15_000 }, + ) + const setupRefresh = page.waitForResponse( + (r) => r.url().includes('/api/setup') && r.request().method() === 'GET' && r.ok(), + { timeout: 15_000 }, + ) + await scenarioSel.selectOption('l4_sedan_kinematics').catch(() => {}) + await fleetLoad.catch(() => {}) + await setupRefresh.catch(() => {}) + await expect(page.getByText(/\d+ points · figure8/)).toBeVisible({ timeout: 10_000 }) + await page.waitForTimeout(400) + } + await page.screenshot({ path: path.join(OUT, '01-scenario-l4.png'), fullPage: true }) + + await openMode(page, 'build') + await page.waitForTimeout(1200) + await page.screenshot({ path: path.join(OUT, '02-build-default.png'), fullPage: true }) + + const lvl = page.locator('select#lvl') + if (await lvl.count()) { + const asm = page.waitForResponse( + (r) => r.url().includes('/api/catalog/assembly') && r.ok(), + { timeout: 15_000 }, + ) + await lvl.selectOption('L4') + await asm.catch(() => {}) + await page.waitForTimeout(1000) + } + const front = page.locator('button[data-slot="front_chassis"]') + if (await front.count()) { + await front.click() + await page.waitForTimeout(2500) + } + await page.screenshot({ path: path.join(OUT, '03-build-l4-hardpoint.png'), fullPage: true }) + + await openMode(page, 'analyze') + await page.waitForTimeout(600) + const picks = page.locator('label.pick input[type="checkbox"]') + const n = await picks.count() + for (let i = 0; i < Math.min(n, 2); i++) { + if (!(await picks.nth(i).isChecked())) await picks.nth(i).check() + } + await page.screenshot({ path: path.join(OUT, '04-analyze-idle.png'), fullPage: true }) + const runBtn = page.getByRole('button', { name: 'Run compare' }) + if (await runBtn.isEnabled()) { + const cmp = page.waitForResponse( + (r) => r.url().includes('/api/compare') && r.request().method() === 'POST' && r.ok(), + { timeout: 60_000 }, + ) + await runBtn.click() + await cmp.catch(() => {}) + await page.waitForTimeout(1500) + await page.screenshot({ path: path.join(OUT, '05-analyze-results.png'), fullPage: true }) + } + + await openMode(page, 'scenario') + await expect(page.getByRole('button', { name: /Play/ })).toBeEnabled({ timeout: 15_000 }) + const setupPost = page.waitForResponse( + (r) => r.url().includes('/api/setup') && r.request().method() === 'POST' && r.ok(), + { timeout: 25_000 }, + ) + const startPost = page.waitForResponse( + (r) => + (r.url().includes('/api/run/start') || r.url().includes('/api/control')) && + r.request().method() === 'POST' && + r.ok(), + { timeout: 25_000 }, + ) + await page.getByRole('button', { name: /Play/ }).click() + await setupPost + await startPost + await page.waitForTimeout(300) + if (!(await page.getByText('Mode: run').isVisible().catch(() => false))) { + await openMode(page, 'run') + } + await page.waitForTimeout(3000) + await page.screenshot({ path: path.join(OUT, '06-run-sim-t3.png'), fullPage: true }) + await page.waitForTimeout(5000) + await page.screenshot({ path: path.join(OUT, '07-run-sim-t8.png'), fullPage: true }) + await page.getByRole('button', { name: /Stop/ }).click().catch(() => {}) + }) +}) diff --git a/gui/v3/e2e/smoke.spec.ts b/gui/v3/e2e/smoke.spec.ts new file mode 100644 index 00000000..5aa5f307 --- /dev/null +++ b/gui/v3/e2e/smoke.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test' + +type Mode = 'scenario' | 'run' | 'build' | 'analyze' + +async function openMode(page: import('@playwright/test').Page, mode: Mode) { + const setupReady = page.waitForResponse( + (r) => r.url().includes('/api/setup') && r.request().method() === 'GET' && r.ok(), + { timeout: 25_000 }, + ) + await page.goto(`index.html#/${mode}`) + await setupReady + await expect(page.getByText('VDSim', { exact: true })).toBeVisible() + await expect(page.getByText(`Mode: ${mode}`)).toBeVisible({ timeout: 15_000 }) +} + +test.describe('GUI v3 smoke', () => { + test('scenario map and fleet', async ({ page }) => { + await openMode(page, 'scenario') + await expect(page.getByRole('button', { name: /Edit path/ })).toBeVisible() + await expect(page.getByRole('button', { name: /Edit parts/ })).toBeVisible() + await expect(page.getByRole('button', { name: 'Skidpad' })).toBeVisible() + const tpl = page.waitForResponse( + (r) => r.url().includes('/api/setup/template') && r.request().method() === 'POST' && r.ok(), + ) + await page.locator('button.tpl').filter({ hasText: 'Figure-8' }).click() + await tpl + await expect(page.getByText(/\d+ points · figure8/)).toBeVisible() + }) + + test('build assembly', async ({ page }) => { + await openMode(page, 'build') + await expect(page.getByText('Vehicle build')).toBeVisible() + await expect(page.getByRole('button', { name: 'V0' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('button', { name: 'Export' })).toBeVisible() + }) + + test('analyze compare panel', async ({ page }) => { + await openMode(page, 'analyze') + await expect(page.getByText('ISO compare')).toBeVisible() + await expect(page.getByRole('button', { name: 'Run compare' })).toBeVisible() + }) + + test('run viewport shell', async ({ page }) => { + await openMode(page, 'run') + await expect(page.getByRole('button', { name: 'Orbit' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Chase' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Top', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Autopilot' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Manual' })).toBeVisible() + }) +}) diff --git a/gui/v3/index.html b/gui/v3/index.html new file mode 100644 index 00000000..a86619f2 --- /dev/null +++ b/gui/v3/index.html @@ -0,0 +1,13 @@ + + + + + + + VDSim + + +
+ + + diff --git a/gui/v3/package-lock.json b/gui/v3/package-lock.json new file mode 100644 index 00000000..74f2d653 --- /dev/null +++ b/gui/v3/package-lock.json @@ -0,0 +1,1409 @@ +{ + "name": "vdsim-gui-v3", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vdsim-gui-v3", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.51.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^24.12.3", + "@types/three": "^0.184.1", + "svelte": "^5.55.5", + "svelte-check": "^4.4.8", + "three": "^0.172.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.12.tgz", + "integrity": "sha512-On0QbLyaiAkVC4eXtgnXK9Kh2opit+3rcUSOc45DqJ2s/X2eXAHsGOKRSJ6IDagQEW5vPyivANfXUiqgXC67Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", + "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.11", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.6.0.tgz", + "integrity": "sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "0.1.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/three": { + "version": "0.172.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz", + "integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/gui/v3/package.json b/gui/v3/package.json new file mode 100644 index 00000000..a2ed0264 --- /dev/null +++ b/gui/v3/package.json @@ -0,0 +1,26 @@ +{ + "name": "vdsim-gui-v3", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.51.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@tsconfig/svelte": "^5.0.8", + "@types/node": "^24.12.3", + "@types/three": "^0.184.1", + "svelte": "^5.55.5", + "svelte-check": "^4.4.8", + "three": "^0.172.0", + "typescript": "~6.0.2", + "vite": "^8.0.12" + } +} diff --git a/gui/v3/playwright.config.ts b/gui/v3/playwright.config.ts new file mode 100644 index 00000000..d0ca4979 --- /dev/null +++ b/gui/v3/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test' + +const port = process.env.VDSIM_GUI_PORT ?? '8095' + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + use: { + baseURL: `http://127.0.0.1:${port}/v3/`, + headless: true, + viewport: { width: 1280, height: 800 }, + trace: 'retain-on-failure', + }, + reporter: [['list']], +}) diff --git a/gui/v3/public/favicon.svg b/gui/v3/public/favicon.svg new file mode 100644 index 00000000..6893eb13 --- /dev/null +++ b/gui/v3/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/v3/public/icons.svg b/gui/v3/public/icons.svg new file mode 100644 index 00000000..e9522193 --- /dev/null +++ b/gui/v3/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gui/v3/src/App.svelte b/gui/v3/src/App.svelte new file mode 100644 index 00000000..cd03cc0b --- /dev/null +++ b/gui/v3/src/App.svelte @@ -0,0 +1,169 @@ + + +
+
+
VDSim
+ +
+ {#if setupStore.loadedScenario} + {setupStore.loadedScenario}.yaml + {/if} + {draftLabel} + + {connected ? '●' : '○'} api +
+
+ +
+ {#if mode === 'scenario'} + + {:else if mode === 'run'} + + {:else if mode === 'build'} + + {:else} + + {/if} +
+ +
+ Mode: {mode} + {#if setupStore.setup?.running} + sim running + {/if} + Map: drag fleet · Path from preset · Sync before Play +
+
+ + diff --git a/gui/v3/src/app.css b/gui/v3/src/app.css new file mode 100644 index 00000000..74da1e82 --- /dev/null +++ b/gui/v3/src/app.css @@ -0,0 +1,32 @@ +@import './tokens.css'; + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + font-family: var(--font); + font-size: var(--fs-md); + color: var(--ink); + background: var(--bg); +} + +#app { + height: 100%; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input, +select { + font-family: inherit; + font-size: var(--fs-sm); +} diff --git a/gui/v3/src/assets/hero.png b/gui/v3/src/assets/hero.png new file mode 100644 index 00000000..02251f4b Binary files /dev/null and b/gui/v3/src/assets/hero.png differ diff --git a/gui/v3/src/assets/svelte.svg b/gui/v3/src/assets/svelte.svg new file mode 100644 index 00000000..c5e08481 --- /dev/null +++ b/gui/v3/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/gui/v3/src/assets/vite.svg b/gui/v3/src/assets/vite.svg new file mode 100644 index 00000000..5101b674 --- /dev/null +++ b/gui/v3/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/gui/v3/src/components/HardpointEditor.svelte b/gui/v3/src/components/HardpointEditor.svelte new file mode 100644 index 00000000..7c38ae51 --- /dev/null +++ b/gui/v3/src/components/HardpointEditor.svelte @@ -0,0 +1,456 @@ + + +
+
+ {sideLabel} hardpoints + {#if kin} + {kin.type} · {kin.name} + {#if kin.readonly}read-only stock{/if} + {#if previewBusy || dirty}updating…{/if} + {/if} + +
+ + {#if err} +
{err}
+ {/if} + {#if status} +
{status}
+ {/if} + + {#if busy && !kin} +

Loading kinematics…

+ {:else if kin && doc} +
+ + +
+ {#if vp} + + + {#each schematic.links as [a, b], i (i)} + + {/each} + {#each paths as path (path)} + {@const p = geometry[path]} + {#if p} + onPtrDown(path, e)} + /> + {/if} + {/each} + + {/if} +
Side view (y–z) · drag ● or edit coordinates
+
+ + +
+ {/if} +
+ + diff --git a/gui/v3/src/components/HelpLink.svelte b/gui/v3/src/components/HelpLink.svelte new file mode 100644 index 00000000..274c48a4 --- /dev/null +++ b/gui/v3/src/components/HelpLink.svelte @@ -0,0 +1,38 @@ + + +{#if href} + {label} +{/if} + + diff --git a/gui/v3/src/components/MapCanvas.svelte b/gui/v3/src/components/MapCanvas.svelte new file mode 100644 index 00000000..4ff80d63 --- /dev/null +++ b/gui/v3/src/components/MapCanvas.svelte @@ -0,0 +1,214 @@ + + + + + diff --git a/gui/v3/src/components/PartsLibrary.svelte b/gui/v3/src/components/PartsLibrary.svelte new file mode 100644 index 00000000..57b35c84 --- /dev/null +++ b/gui/v3/src/components/PartsLibrary.svelte @@ -0,0 +1,533 @@ + + +
+
+ + Parts library + {#if status}{status}{/if} +
+ + {#if err}
{err}
{/if} + +
+ {#each PART_LIB_TYPES as [t, lab] (t)} + + {/each} +
+ +
+ + + +
+ +
+
+ {#each parts as p (p.id)} + + {/each} + +
+ +
+ {#if editor} + {#if !editable} +

Builtin part — clone to edit.

+ {/if} + +
+ + + {#if selId} + + {/if} + {#if editor.editable && selId} + + {/if} + + +
+ +
+ + + + {#if schema?.meta_fields} +
Metadata
+ {#each schema.meta_fields as [name, lab, kind] (name)} + + {/each} + {/if} + + {#if schema?.fields} +
Physics
+ {#each schema.fields as [name, lab, kind] (name)} + + {/each} + {/if} + + {#if schema?.array_fields} + {#each schema.array_fields as [name, lab, n] (name)} + {#each Array(n) as _, i} + + {/each} + {/each} + {/if} + + {#if schema?.bool_fields} + {#each schema.bool_fields as [name, lab] (name)} + + {/each} + {/if} +
+ + {#if editor.yaml} +
+ YAML preview +
{editor.yaml}
+
+ {/if} + {:else if busy} +

Loading…

+ {:else} +

Select or create a part.

+ {/if} +
+
+
+ + diff --git a/gui/v3/src/components/ViewportPane.svelte b/gui/v3/src/components/ViewportPane.svelte new file mode 100644 index 00000000..c9692c4b --- /dev/null +++ b/gui/v3/src/components/ViewportPane.svelte @@ -0,0 +1,59 @@ + + +
+ + diff --git a/gui/v3/src/lib/api/catalog.ts b/gui/v3/src/lib/api/catalog.ts new file mode 100644 index 00000000..80669e04 --- /dev/null +++ b/gui/v3/src/lib/api/catalog.ts @@ -0,0 +1,260 @@ +import { fetchJson } from './client' + +export interface PartCard { + tier?: string + lines?: string[] + blurb?: string +} + +export interface PartCompat { + level: string + msg?: string +} + +export interface AssemblyCandidate { + id: string + label: string + tags?: string[] + card?: PartCard + compat?: PartCompat[] + ok?: boolean +} + +export interface AssemblySlot { + slot: string + label: string + type: string + part_id: string + part_label: string + part_stem: string + candidates: AssemblyCandidate[] +} + +export interface AssemblySummary { + mass_kg?: number + wheelbase_m?: number + cg_height_m?: number + track_front_m?: number + track_rear_m?: number + wheel_radius_m?: number + tire_mu?: number + drive_type?: string + error?: string +} + +export interface AssemblyPreview { + slot: string + candidate: string + summary: AssemblySummary + delta: Record + candidate_info?: AssemblyCandidate +} + +export interface AssemblyView { + blueprint: { id: string; label: string; default_level?: string } + blueprints: { id: string; label: string; level?: string }[] + recommended: { id: string; label: string }[] + categories: { id: string; label: string; slots: string[] }[] + level: string + parts: Record + slots: AssemblySlot[] + summary: AssemblySummary + build_complete: boolean + preview?: AssemblyPreview +} + +export interface SuspensionSchematic { + name: string + type: string + links: [[number, number, number], [number, number, number]][] + points?: Record +} + +export const FLEET_LEVELS = ['K', 'L1', 'L2', 'L3', 'L4', 'L5'] as const + +export async function fetchAssembly( + vehicleId: number, + opts?: { slot?: string; candidate?: string }, +): Promise { + const qs = new URLSearchParams({ vehicle_id: String(vehicleId) }) + if (opts?.slot) qs.set('slot', opts.slot) + if (opts?.candidate) qs.set('candidate', opts.candidate) + const r = await fetchJson<{ ok: boolean; assembly: AssemblyView }>( + `/api/catalog/assembly?${qs}`, + ) + return r.assembly +} + +export async function fetchSuspensionSchematic( + name: string, +): Promise { + if (!name) return null + const r = await fetchJson( + `/api/suspension/schematic?name=${encodeURIComponent(name)}`, + ) + if (r.ok === false) return null + return r +} + +export function chassisStem(partId: string): string { + const dot = partId.indexOf('.') + return dot >= 0 ? partId.slice(dot + 1) : partId +} + +export function slotCompatTone(slot: AssemblySlot): '' | 'warn' | 'err' { + const cur = slot.candidates?.find((c) => c.id === slot.part_id) + if (!cur?.compat?.length) return '' + if (cur.compat.some((c) => c.level === 'error')) return 'err' + if (cur.compat.some((c) => c.level === 'warn')) return 'warn' + return '' +} + +export function fmtDelta(key: string, v: number): string { + if (!Number.isFinite(v) || Math.abs(v) < 1e-9) return '' + const sign = v > 0 ? '+' : '' + if (key === 'mass_kg') return `${sign}${v.toFixed(0)} kg` + if (key.includes('_m')) return `${sign}${v.toFixed(3)} m` + if (key === 'tire_mu') return `${sign}${v.toFixed(2)} μ` + return `${sign}${v.toFixed(3)}` +} + +export const PART_LIB_TYPES = [ + ['body', 'Body'], + ['aero', 'Aero'], + ['ride', 'Ride'], + ['chassis', 'Chassis'], + ['tire', 'Tire'], + ['brake', 'Brake'], + ['steering', 'Steering'], + ['drivetrain', 'Drivetrain'], +] as const + +const INSTALL_SLOT: Record = { + body: 'body', + aero: 'aero', + ride: 'ride', + tire: 'tire', + brake: 'brake', + steering: 'steering', + drivetrain: 'drivetrain', +} + +export function installSlotForPartType(typeName: string, partId: string): string | null { + if (typeName === 'chassis') { + const s = partId.toLowerCase() + if (s.includes('rear')) return 'rear_chassis' + if (s.includes('front')) return 'front_chassis' + return 'front_chassis' + } + return INSTALL_SLOT[typeName] ?? null +} + +export interface CatalogPart { + id: string + label: string + type?: string + tags?: string[] + card?: PartCard +} + +export interface PartSchemaField { + schema?: string + fields?: [string, string, string][] + meta_fields?: [string, string, string][] + array_fields?: [string, string, number][] + bool_fields?: [string, string][] +} + +export interface PartEditor { + ok?: boolean + doc?: { id: string; type: string } + schema?: PartSchemaField + values?: Record + yaml?: string + editable?: boolean +} + +export async function exportBlueprintYaml(blueprintId: string): Promise<{ yaml: string }> { + const r = await fetchJson<{ ok: boolean; yaml: string; blueprint?: unknown }>( + `/api/catalog/blueprints/${encodeURIComponent(blueprintId)}/export`, + ) + return { yaml: r.yaml || '' } +} + +export function downloadText(filename: string, text: string, mime = 'text/yaml'): void { + const blob = new Blob([text], { type: mime }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) +} + +export async function registerBlueprintFromFleet( + vehicleId: number, + stem: string, + label: string, +): Promise<{ blueprint_id?: string }> { + return fetchJson('/api/catalog/blueprint/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vehicle_id: vehicleId, stem, label }), + }) +} + +export async function fetchCatalogParts( + type: string, + opts?: { q?: string; tag?: string; sort?: string }, +): Promise { + const qs = new URLSearchParams({ type }) + if (opts?.q) qs.set('q', opts.q) + if (opts?.tag) qs.set('tag', opts.tag) + if (opts?.sort) qs.set('sort', opts.sort ?? 'label') + const r = await fetchJson<{ parts: CatalogPart[] }>(`/api/catalog/parts?${qs}`) + return r.parts ?? [] +} + +export async function fetchPartEditor(opts: { + type: string + part_id?: string + stem?: string + label?: string + clone?: boolean +}): Promise { + const qs = new URLSearchParams({ type: opts.type }) + if (opts.part_id) qs.set('part_id', opts.part_id) + if (opts.stem) qs.set('stem', opts.stem) + if (opts.label) qs.set('label', opts.label) + if (opts.clone) qs.set('clone', '1') + return fetchJson(`/api/catalog/parts/editor?${qs}`) +} + +export async function savePartFields(body: { + type: string + stem: string + label: string + fields: Record + doc?: unknown +}): Promise<{ part_id: string; package?: string }> { + return fetchJson('/api/catalog/parts/save-fields', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +export async function deleteCatalogPart(partId: string): Promise { + await fetchJson('/api/catalog/parts/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ part_id: partId }), + }) +} + +export async function importPartYaml(yaml: string): Promise<{ part_id: string }> { + return fetchJson('/api/catalog/parts/import-yaml', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ yaml }), + }) +} diff --git a/gui/v3/src/lib/api/client.ts b/gui/v3/src/lib/api/client.ts new file mode 100644 index 00000000..79a05006 --- /dev/null +++ b/gui/v3/src/lib/api/client.ts @@ -0,0 +1,42 @@ +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message) + this.name = 'ApiError' + } +} + +export async function fetchJson(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + headers: { Accept: 'application/json', ...(init?.headers ?? {}) }, + ...init, + }) + const text = await res.text() + let body: unknown = null + if (text) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + if (!res.ok) { + const msg = + typeof body === 'object' && body && 'error' in body + ? String((body as { error: unknown }).error) + : res.statusText + throw new ApiError(msg || `HTTP ${res.status}`, res.status) + } + return body as T +} + +export async function pingApi(): Promise { + try { + await fetchJson('/api/state') + return true + } catch { + return false + } +} diff --git a/gui/v3/src/lib/api/compare.ts b/gui/v3/src/lib/api/compare.ts new file mode 100644 index 00000000..863e24e7 --- /dev/null +++ b/gui/v3/src/lib/api/compare.ts @@ -0,0 +1,83 @@ +import { fetchJson } from './client' + +export const COMPARE_MANEUVERS = [ + { id: 'step_steer', label: 'Step steer' }, + { id: 'skidpad', label: 'Skidpad understeer' }, + { id: 'dlc', label: 'Double lane change' }, +] as const + +export type CompareManeuverId = (typeof COMPARE_MANEUVERS)[number]['id'] + +export interface CompareTrace { + t: number[] + r: number[] +} + +export interface CompareRow { + vehicle: string + [key: string]: string | number | undefined +} + +export interface CompareResult { + ok: boolean + rows: CompareRow[] + traces: Record + columns: string[] + maneuvers: string[] + error?: string +} + +export interface CompareRequest { + vehicles: string[] + maneuvers?: CompareManeuverId[] + tire?: string | null + level?: string +} + +export async function runCompare(req: CompareRequest): Promise { + return fetchJson('/api/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(req), + }) +} + +export function vehicleLabel(id: string): string { + return id.replace(/^vehicle\./, '') +} + +export function cmpFmt(v: unknown): string { + const n = Number(v) + return Number.isFinite(n) ? String(parseFloat(n.toPrecision(4))) : String(v ?? '') +} + +export function cmpDeltaPct( + base: unknown, + v: unknown, +): { text: string; neg: boolean } | null { + const b = Number(base) + const x = Number(v) + if (!Number.isFinite(b) || !Number.isFinite(x) || Math.abs(b) <= 1e-9) return null + const d = ((x - b) / Math.abs(b)) * 100 + if (Math.abs(d) < 0.05) return null + return { text: `${d > 0 ? '+' : ''}${d.toFixed(1)}%`, neg: d < 0 } +} + +export function rowsToCsv(rows: CompareRow[], columns: string[]): string { + const cols = ['vehicle', ...columns] + const esc = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s) + const lines = [cols.join(',')] + for (const r of rows) { + lines.push(cols.map((c) => esc(String(r[c] ?? ''))).join(',')) + } + return lines.join('\n') +} + +export function downloadCsv(filename: string, csv: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = filename + a.click() + URL.revokeObjectURL(a.href) +} diff --git a/gui/v3/src/lib/api/control.ts b/gui/v3/src/lib/api/control.ts new file mode 100644 index 00000000..52e1db89 --- /dev/null +++ b/gui/v3/src/lib/api/control.ts @@ -0,0 +1,47 @@ +import { fetchJson } from './client' + +export async function controlAction(action: 'start' | 'stop' | 'pause' | 'resume' | 'reset') { + return fetchJson<{ ok: boolean; running?: boolean; error?: string }>('/api/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action }), + }) +} + +export async function setTimeScale(scale: number) { + return fetchJson('/api/sim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ time_scale: scale }), + }) +} + +export async function getTimeScale(): Promise { + try { + const j = await fetchJson<{ config?: { time_scale?: number } }>('/api/config') + return j.config?.time_scale ?? 1 + } catch { + return 1 + } +} + +export async function setFleetDriver(vid: number, driver: boolean) { + return fetchJson<{ ok: boolean; driver?: boolean }>('/api/fleet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vehicle_id: vid, driver }), + }) +} + +export async function postManual( + vid: number, + throttle: number, + brake: number, + steer: number, +) { + return fetchJson('/api/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vehicle: vid, throttle, brake, steer }), + }) +} diff --git a/gui/v3/src/lib/api/setup.ts b/gui/v3/src/lib/api/setup.ts new file mode 100644 index 00000000..7e435cba --- /dev/null +++ b/gui/v3/src/lib/api/setup.ts @@ -0,0 +1,80 @@ +import { fetchJson } from './client' +import type { ApiOk, Setup, SetupPatch } from './types' + +export async function getSetup(): Promise { + return fetchJson('/api/setup') +} + +export async function getPathPoints(): Promise<[number, number][]> { + const r = await fetchJson<{ pts: [number, number][] }>('/api/path') + return r.pts ?? [] +} + +export async function postSetup(patch: SetupPatch): Promise { + return fetchJson('/api/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) +} + +export async function applyScenarioTemplate( + template: string, +): Promise { + return fetchJson('/api/setup/template', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ template }), + }) +} + +export async function loadScenario(name: string): Promise { + return fetchJson('/api/fleet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scenario: name }), + }) +} + +export async function saveScenario(name: string, overwrite = false): Promise { + return fetchJson('/api/scenario/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, overwrite }), + }) +} + +export async function addFleetVehicle(): Promise { + return fetchJson('/api/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fleet_add: true }), + }) +} + +export async function startRun(): Promise { + try { + return await fetchJson('/api/run/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + } catch { + return fetchJson('/api/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'start' }), + }) + } +} + +export const RAD2DEG = 180 / Math.PI +export const DEG2RAD = Math.PI / 180 + +export function roadGradeDeg(road: { grade?: number }): number { + return (road.grade ?? 0) * RAD2DEG +} + +export function roadBankDeg(road: { bank?: number }): number { + return (road.bank ?? 0) * RAD2DEG +} diff --git a/gui/v3/src/lib/api/suspension.ts b/gui/v3/src/lib/api/suspension.ts new file mode 100644 index 00000000..146870b1 --- /dev/null +++ b/gui/v3/src/lib/api/suspension.ts @@ -0,0 +1,64 @@ +import { fetchJson } from './client' + +export type Vec3 = [number, number, number] + +export interface KinSchematic { + links: [Vec3, Vec3][] + points?: Record +} + +export interface KcPlot { + title: string + xlabel?: string + ylabel?: string + series: { x: number[]; y: number[]; label?: string; color?: string }[] +} + +export interface SuspensionKin { + ok?: boolean + name: string + part_id: string + type: string + doc: Record + geometry: Record + schematic: KinSchematic + path: string + readonly: boolean + plots?: KcPlot[] +} + +export interface KinPreview { + ok?: boolean + type: string + geometry: Record + schematic: KinSchematic + plots: KcPlot[] +} + +export async function fetchSuspensionKin(partId: string): Promise { + const qs = new URLSearchParams({ part_id: partId }) + return fetchJson(`/api/suspension/kin?${qs}`) +} + +export async function previewSuspensionKin( + doc: Record, +): Promise { + return fetchJson('/api/suspension/kin/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ doc }), + }) +} + +export async function saveSuspensionKin(body: { + doc: Record + stem: string + label: string + base_part_id?: string +}): Promise<{ ok?: boolean; part_id: string; label: string }> { + return fetchJson('/api/suspension/kin/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} diff --git a/gui/v3/src/lib/api/types.ts b/gui/v3/src/lib/api/types.ts new file mode 100644 index 00000000..83c64233 --- /dev/null +++ b/gui/v3/src/lib/api/types.ts @@ -0,0 +1,59 @@ +export interface FleetAgent { + id: number + blueprint: string + level?: string + label?: string + x0?: number + y0?: number + yaw0?: number + vx0?: number + driver?: boolean + parts?: Record +} + +export interface SetupRoad { + mu: number + mu_right?: number + mu_boundary?: number + grade: number + bank: number + rough_amp?: number + rough_wl?: number +} + +export interface Setup { + running: boolean + paused?: boolean + path_preset: string + path_pts: [number, number][] + fleet: FleetAgent[] + road: SetupRoad + v_target: number + level?: string + vehicle?: string + scenarios?: string[] + scenes?: string[] + cosim_attach?: boolean + infra_sensors?: unknown[] + stunt?: Record +} + +export interface SetupPatch { + path_preset?: string + path_pts?: [number, number][] + fleet?: Partial[] + road?: Partial + v_target?: number + fleet_add?: boolean + fleet_remove?: number + cosim_attach?: boolean +} + +export interface ApiOk { + ok: boolean + error?: string + setup?: Setup + fleet?: FleetAgent[] + live_vid?: number + running?: boolean +} diff --git a/gui/v3/src/lib/charts/kcPlots.ts b/gui/v3/src/lib/charts/kcPlots.ts new file mode 100644 index 00000000..6b98bb32 --- /dev/null +++ b/gui/v3/src/lib/charts/kcPlots.ts @@ -0,0 +1,47 @@ +import type { KcPlot } from '../api/suspension' + +export function kcPlotSvg(p: KcPlot, opts?: { width?: number; height?: number }): string { + const W = opts?.width ?? 280 + const H = opts?.height ?? 120 + const pad = { l: 42, r: 8, t: 18, b: 22 } + let xmin = Infinity + let xmax = -Infinity + let ymin = Infinity + let ymax = -Infinity + for (const s of p.series) { + for (let i = 0; i < s.x.length; i++) { + xmin = Math.min(xmin, s.x[i]) + xmax = Math.max(xmax, s.x[i]) + ymin = Math.min(ymin, s.y[i]) + ymax = Math.max(ymax, s.y[i]) + } + } + if (!Number.isFinite(xmin)) return '' + if (ymin === ymax) { + ymin -= 1 + ymax += 1 + } + if (xmin === xmax) xmax = xmin + 1 + const py = (ymax - ymin) * 0.06 + ymin -= py + ymax += py + const X = (x: number) => pad.l + ((x - xmin) / (xmax - xmin)) * (W - pad.l - pad.r) + const Y = (y: number) => H - pad.b - ((y - ymin) / (ymax - ymin)) * (H - pad.t - pad.b) + + let svg = `` + svg += `` + svg += `${p.title}` + if (p.ylabel) { + svg += `${p.ylabel}` + } + if (p.xlabel) { + svg += `${p.xlabel}` + } + for (const s of p.series) { + const c = s.color ?? '#01A0E9' + const pts = s.x.map((x, i) => `${X(x).toFixed(1)},${Y(s.y[i]).toFixed(1)}`).join(' ') + svg += `` + } + svg += '' + return svg +} diff --git a/gui/v3/src/lib/charts/yawRateChart.ts b/gui/v3/src/lib/charts/yawRateChart.ts new file mode 100644 index 00000000..36315e36 --- /dev/null +++ b/gui/v3/src/lib/charts/yawRateChart.ts @@ -0,0 +1,53 @@ +import type { CompareTrace } from '../api/compare' + +const PALETTE = ['#01A0E9', '#DC291E', '#2a8a4a', '#9b59b6', '#e6a817', '#333333'] + +export function yawRateOverlaySvg( + traces: Record, + opts?: { width?: number; height?: number }, +): string { + const names = Object.keys(traces) + if (!names.length) return '' + + const W = opts?.width ?? 720 + const H = opts?.height ?? 220 + const pad = { l: 50, r: 12, t: 22, b: 26 } + + let tmin = Infinity + let tmax = -Infinity + let rmin = Infinity + let rmax = -Infinity + for (const n of names) { + const tr = traces[n] + for (const x of tr.t) { + tmin = Math.min(tmin, x) + tmax = Math.max(tmax, x) + } + for (const y of tr.r) { + rmin = Math.min(rmin, y) + rmax = Math.max(rmax, y) + } + } + rmin = Math.min(rmin, 0) + rmax = Math.max(rmax, 0) + + const X = (t: number) => pad.l + ((t - tmin) / (tmax - tmin || 1)) * (W - pad.l - pad.r) + const Y = (r: number) => H - pad.b - ((r - rmin) / (rmax - rmin || 1)) * (H - pad.t - pad.b) + + let svg = `` + svg += `` + svg += `` + svg += `yaw rate r [rad/s]` + svg += `t [s]` + + names.forEach((n, i) => { + const tr = traces[n] + const c = PALETTE[i % PALETTE.length] + const pts = tr.t.map((x, j) => `${X(x).toFixed(1)},${Y(tr.r[j]).toFixed(1)}`).join(' ') + svg += `` + const lab = n.replace(/^vehicle\./, '') + svg += `${lab}` + }) + svg += '' + return svg +} diff --git a/gui/v3/src/lib/docsHelp.ts b/gui/v3/src/lib/docsHelp.ts new file mode 100644 index 00000000..2c61c20b --- /dev/null +++ b/gui/v3/src/lib/docsHelp.ts @@ -0,0 +1,16 @@ +export const DOCS = 'https://onlyti.github.io/VDSim/main/theory/' + +export const DOC_MAP: Record = { + road: '03_tire_pacejka_mf96', + run: '09_pure_pursuit_path', + frames: '01_frames_and_conventions', + Tire: '03_tire_pacejka_mf96', + Steering: '05_ld2_seven_dof', + numerics: '11_numerical_integration', + cosim: '18_runtime_and_cosim', +} + +export function docHref(key: string): string | null { + const f = DOC_MAP[key] + return f === undefined ? null : DOCS + (f ? `${f}/` : '') +} diff --git a/gui/v3/src/lib/kin/kinDoc.ts b/gui/v3/src/lib/kin/kinDoc.ts new file mode 100644 index 00000000..7418b5a0 --- /dev/null +++ b/gui/v3/src/lib/kin/kinDoc.ts @@ -0,0 +1,29 @@ +import type { Vec3 } from '../api/suspension' + +export function isDraggablePath(path: string): boolean { + if (path.includes('spin_axis') || path.endsWith('.static_radius')) return false + return /^(lca|uca|strut|tie_rod|spring_damper|arm_pivot|links|wheel\.center)/.test(path) +} + +export function setKinPath( + doc: Record, + path: string, + value: Vec3, +): Record { + const parts = path.split('.') + let node = doc + for (let i = 0; i < parts.length - 1; i++) { + node = node[parts[i]] as Record + } + node[parts[parts.length - 1]] = [value[0], value[1], value[2]] + return doc +} + +export function shortPath(path: string): string { + const p = path.split('.') + return p.length > 2 ? p.slice(-2).join('.') : path +} + +export function draggablePaths(geometry: Record): string[] { + return Object.keys(geometry).filter(isDraggablePath).sort() +} diff --git a/gui/v3/src/lib/kin/kinViewport.ts b/gui/v3/src/lib/kin/kinViewport.ts new file mode 100644 index 00000000..921d1742 --- /dev/null +++ b/gui/v3/src/lib/kin/kinViewport.ts @@ -0,0 +1,77 @@ +export interface YzViewport { + W: number + H: number + margin: number + scale: number + y0: number + z0: number +} + +export function collectYzPts( + links: [number, number, number][][], + points: Record | undefined, + geometry: Record | undefined, +): [number, number][] { + const pts: [number, number][] = [] + for (const [a, b] of links) { + pts.push([a[1], a[2]], [b[1], b[2]]) + } + for (const p of Object.values(points ?? {})) { + pts.push([p[1], p[2]]) + } + for (const p of Object.values(geometry ?? {})) { + pts.push([p[1], p[2]]) + } + return pts +} + +export function fitYzViewport(pts: [number, number][], W: number, H: number): YzViewport | null { + if (!pts.length || W < 20 || H < 20) return null + let ymin = pts[0][0] + let ymax = pts[0][0] + let zmin = pts[0][1] + let zmax = pts[0][1] + for (const [y, z] of pts) { + if (y < ymin) ymin = y + if (y > ymax) ymax = y + if (z < zmin) zmin = z + if (z > zmax) zmax = z + } + const pad = 0.08 + ymin -= pad + ymax += pad + zmin -= pad + zmax += pad + const spanY = Math.max(ymax - ymin, 0.2) + const spanZ = Math.max(zmax - zmin, 0.2) + const margin = 14 + const drawW = W - margin * 2 + const drawH = H - margin * 2 + const scale = Math.min(drawW / spanY, drawH / spanZ) + const cx = 0.5 * (ymin + ymax) + const cz = 0.5 * (zmin + zmax) + const visY = drawW / scale + const visZ = drawH / scale + return { + W, + H, + margin, + scale, + y0: cx - visY * 0.5, + z0: cz - visZ * 0.5, + } +} + +export function vpX(vp: YzViewport, y: number): number { + return vp.margin + (y - vp.y0) * vp.scale +} + +export function vpY(vp: YzViewport, z: number): number { + return vp.H - vp.margin - (z - vp.z0) * vp.scale +} + +export function vpScreenToYz(vp: YzViewport, sx: number, sy: number): [number, number] { + const y = vp.y0 + (sx - vp.margin) / vp.scale + const z = vp.z0 + (vp.H - vp.margin - sy) / vp.scale + return [y, z] +} diff --git a/gui/v3/src/lib/manualControl.ts b/gui/v3/src/lib/manualControl.ts new file mode 100644 index 00000000..dc6fa8ea --- /dev/null +++ b/gui/v3/src/lib/manualControl.ts @@ -0,0 +1,83 @@ +import { postManual } from './api/control' + +const ARROW_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']) + +export type ManualCmd = { throttle: number; brake: number; steer: number } + +export function createManualControl(opts: { + isManual: () => boolean + isRunning: () => boolean + getVid: () => number +}) { + const keys: Record = {} + let touchThr = 0 + let touchBrk = 0 + let touchSteer = 0 + let sendBusy = false + let sendLatest: ManualCmd | null = null + + function sample(): ManualCmd { + const throttle = Math.max(keys.ArrowUp ? 0.7 : 0, touchThr) + const brake = Math.max(keys.ArrowDown ? 0.6 : 0, touchBrk) + const steer = Math.max( + -0.4, + Math.min(0.4, (keys.ArrowLeft ? 0.25 : 0) - (keys.ArrowRight ? 0.25 : 0) + touchSteer), + ) + return { throttle, brake, steer } + } + + async function drain() { + if (sendBusy) return + sendBusy = true + while (sendLatest) { + const cmd = sendLatest + sendLatest = null + try { + await postManual(opts.getVid(), cmd.throttle, cmd.brake, cmd.steer) + } catch { + /* optional */ + } + } + sendBusy = false + } + + function push() { + if (!opts.isRunning() || !opts.isManual()) { + sendLatest = null + return + } + sendLatest = sample() + void drain() + } + + function onKeyDown(e: KeyboardEvent) { + if (opts.isManual() && opts.isRunning() && ARROW_KEYS.has(e.key)) e.preventDefault() + keys[e.key] = true + push() + } + + function onKeyUp(e: KeyboardEvent) { + keys[e.key] = false + push() + } + + const timer = setInterval(push, 40) + + return { + setTouch(throttle: number, brake: number, steer: number) { + touchThr = throttle + touchBrk = brake + touchSteer = steer + push() + }, + dispose() { + clearInterval(timer) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + }, + bind() { + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + }, + } +} diff --git a/gui/v3/src/lib/map/pathUtils.ts b/gui/v3/src/lib/map/pathUtils.ts new file mode 100644 index 00000000..a025319f --- /dev/null +++ b/gui/v3/src/lib/map/pathUtils.ts @@ -0,0 +1,13 @@ +export function downsamplePathPts( + pts: [number, number][], + maxN = 16, +): [number, number][] { + if (pts.length <= maxN) return pts.map((p) => [p[0], p[1]]) + const out: [number, number][] = [] + const step = (pts.length - 1) / (maxN - 1) + for (let i = 0; i < maxN; i++) { + const p = pts[Math.round(i * step)] + out.push([p[0], p[1]]) + } + return out +} diff --git a/gui/v3/src/lib/map/viewport.ts b/gui/v3/src/lib/map/viewport.ts new file mode 100644 index 00000000..f7bfb787 --- /dev/null +++ b/gui/v3/src/lib/map/viewport.ts @@ -0,0 +1,230 @@ +import type { FleetAgent } from '../api/types' + +export interface Viewport { + xmin: number + xmax: number + ymin: number + ymax: number + scale: number + width: number + height: number + pad: number +} + +export function fitViewport( + points: [number, number][], + width: number, + height: number, + pad = 16, +): Viewport { + let xmin = 0 + let xmax = 40 + let ymin = -20 + let ymax = 20 + if (points.length) { + xmin = xmax = points[0][0] + ymin = ymax = points[0][1] + for (const [x, y] of points) { + if (x < xmin) xmin = x + if (x > xmax) xmax = x + if (y < ymin) ymin = y + if (y > ymax) ymax = y + } + } + const span = Math.max(xmax - xmin, ymax - ymin, 24) + const cx = 0.5 * (xmin + xmax) + const cy = 0.5 * (ymin + ymax) + const content = span * 1.1 + 16 + const drawW = Math.max(width - 2 * pad, 1) + const drawH = Math.max(height - 2 * pad, 1) + const scale = Math.min(drawW / content, drawH / content) + const visibleW = drawW / scale + const visibleH = drawH / scale + return { + xmin: cx - visibleW * 0.5, + xmax: cx + visibleW * 0.5, + ymin: cy - visibleH * 0.5, + ymax: cy + visibleH * 0.5, + scale, + width, + height, + pad, + } +} + +export function worldToScreen(vp: Viewport, x: number, y: number): [number, number] { + const { pad, height, xmin, ymin, scale } = vp + const sx = pad + (x - xmin) * scale + const sy = height - pad - (y - ymin) * scale + return [sx, sy] +} + +export function screenToWorld(vp: Viewport, sx: number, sy: number): [number, number] { + const { pad, height, xmin, ymin, scale } = vp + const x = xmin + (sx - pad) / scale + const y = ymin + (height - pad - sy) / scale + return [x, y] +} + +export function drawMapScene( + ctx: CanvasRenderingContext2D, + vp: Viewport, + path: [number, number][], + fleet: FleetAgent[], + selectedVid: number, + pathEdit?: { selectedWp?: number }, +) { + const { width, height } = vp + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = '#eef1f5' + ctx.fillRect(0, 0, width, height) + + ctx.strokeStyle = '#d8dee8' + ctx.lineWidth = 1 + const gridStep = niceGridStep(vp.xmax - vp.xmin) + for (let gx = Math.floor(vp.xmin / gridStep) * gridStep; gx <= vp.xmax; gx += gridStep) { + const [sx] = worldToScreen(vp, gx, 0) + ctx.beginPath() + ctx.moveTo(sx, 0) + ctx.lineTo(sx, height) + ctx.stroke() + } + for (let gy = Math.floor(vp.ymin / gridStep) * gridStep; gy <= vp.ymax; gy += gridStep) { + const [, sy] = worldToScreen(vp, 0, gy) + ctx.beginPath() + ctx.moveTo(0, sy) + ctx.lineTo(width, sy) + ctx.stroke() + } + + for (let gy = Math.floor(vp.ymin / gridStep) * gridStep; gy <= vp.ymax; gy += gridStep) { + const [, sy] = worldToScreen(vp, 0, gy) + ctx.beginPath() + ctx.moveTo(0, sy) + ctx.lineTo(width, sy) + ctx.stroke() + } + + ctx.fillStyle = '#8b95a5' + ctx.font = '9px system-ui' + ctx.textAlign = 'center' + ctx.textBaseline = 'top' + for (let gx = Math.floor(vp.xmin / gridStep) * gridStep; gx <= vp.xmax; gx += gridStep) { + const [sx] = worldToScreen(vp, gx, 0) + if (sx >= 4 && sx <= width - 4) ctx.fillText(`${Math.round(gx)}`, sx, 2) + } + ctx.textAlign = 'right' + ctx.textBaseline = 'middle' + for (let gy = Math.floor(vp.ymin / gridStep) * gridStep; gy <= vp.ymax; gy += gridStep) { + const [, sy] = worldToScreen(vp, 0, gy) + if (sy >= 10 && sy <= height - 4) ctx.fillText(`${Math.round(gy)}`, width - 3, sy) + } + + if (path.length > 1) { + ctx.strokeStyle = '#2f6d8f' + ctx.lineWidth = 2 + ctx.beginPath() + path.forEach(([x, y], i) => { + const [sx, sy] = worldToScreen(vp, x, y) + if (i === 0) ctx.moveTo(sx, sy) + else ctx.lineTo(sx, sy) + }) + if (path.length > 2) { + const [x0, y0] = path[0] + const [xn, yn] = path[path.length - 1] + if (Math.hypot(x0 - xn, y0 - yn) < 2) { + const [sx, sy] = worldToScreen(vp, x0, y0) + ctx.lineTo(sx, sy) + } + } + ctx.stroke() + + ctx.fillStyle = '#01a0e9' + path.forEach(([x, y], i) => { + const [sx, sy] = worldToScreen(vp, x, y) + const sel = pathEdit?.selectedWp === i + ctx.beginPath() + ctx.arc(sx, sy, pathEdit ? (sel ? 7 : 5) : 4, 0, Math.PI * 2) + ctx.fillStyle = i === 0 ? '#34c759' : sel ? '#ff7a18' : '#01a0e9' + ctx.fill() + if (pathEdit) { + ctx.strokeStyle = sel ? '#1a1d24' : '#ffffff' + ctx.lineWidth = sel ? 2 : 1 + ctx.stroke() + ctx.fillStyle = '#1a1d24' + ctx.font = '9px system-ui' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(String(i + 1), sx, sy) + } + }) + ctx.fillStyle = '#01a0e9' + } + + const colors = ['#01a0e9', '#ff9500', '#34c759', '#af52de'] + fleet.forEach((agent, i) => { + const x = agent.x0 ?? 0 + const y = agent.y0 ?? 0 + const [sx, sy] = worldToScreen(vp, x, y) + const sel = agent.id === selectedVid + ctx.save() + ctx.translate(sx, sy) + ctx.rotate(-(agent.yaw0 ?? 0)) + ctx.fillStyle = colors[i % colors.length] + ctx.strokeStyle = sel ? '#1a1d24' : '#ffffff' + ctx.lineWidth = sel ? 2.5 : 1.5 + ctx.beginPath() + ctx.moveTo(10, 0) + ctx.lineTo(-7, 5) + ctx.lineTo(-4, 0) + ctx.lineTo(-7, -5) + ctx.closePath() + ctx.fill() + ctx.stroke() + ctx.restore() + + ctx.fillStyle = '#1a1d24' + ctx.font = '10px system-ui' + ctx.textAlign = 'center' + ctx.fillText(`V${agent.id}`, sx, sy - 12) + }) +} + +function niceGridStep(span: number): number { + const raw = span / 8 + const mag = 10 ** Math.floor(Math.log10(raw)) + const n = raw / mag + if (n < 1.5) return mag + if (n < 3.5) return 2 * mag + if (n < 7.5) return 5 * mag + return 10 * mag +} + +export function hitFleetAgent( + vp: Viewport, + fleet: FleetAgent[], + sx: number, + sy: number, + radius = 14, +): FleetAgent | null { + for (let i = fleet.length - 1; i >= 0; i--) { + const a = fleet[i] + const [ax, ay] = worldToScreen(vp, a.x0 ?? 0, a.y0 ?? 0) + if (Math.hypot(ax - sx, ay - sy) <= radius) return a + } + return null +} + +export function hitPathWaypoint( + vp: Viewport, + path: [number, number][], + sx: number, + sy: number, + radius = 10, +): number { + for (let i = path.length - 1; i >= 0; i--) { + const [px, py] = worldToScreen(vp, path[i][0], path[i][1]) + if (Math.hypot(px - sx, py - sy) <= radius) return i + } + return -1 +} diff --git a/gui/v3/src/lib/router.ts b/gui/v3/src/lib/router.ts new file mode 100644 index 00000000..c173d7e7 --- /dev/null +++ b/gui/v3/src/lib/router.ts @@ -0,0 +1,32 @@ +export type Mode = 'scenario' | 'run' | 'build' | 'analyze' + +const MODES: Mode[] = ['scenario', 'run', 'build', 'analyze'] + +export function parseMode(hash: string): Mode { + const seg = hash.replace(/^#\/?/, '').split('/')[0] + return MODES.includes(seg as Mode) ? (seg as Mode) : 'scenario' +} + +export function modeHref(mode: Mode): string { + return `#/${mode}` +} + +export function onModeChange(cb: (mode: Mode) => void): () => void { + const fn = () => cb(parseMode(location.hash)) + fn() + window.addEventListener('hashchange', fn) + return () => window.removeEventListener('hashchange', fn) +} + +export function parseVidFromHash(hash: string): number | null { + const q = hash.split('?')[1] + if (!q) return null + const raw = new URLSearchParams(q).get('vid') + if (raw == null) return null + const n = parseInt(raw, 10) + return Number.isFinite(n) ? n : null +} + +export function buildHref(vid?: number): string { + return vid == null ? '#/build' : `#/build?vid=${vid}` +} diff --git a/gui/v3/src/lib/scenarioTemplates.ts b/gui/v3/src/lib/scenarioTemplates.ts new file mode 100644 index 00000000..047e8ea4 --- /dev/null +++ b/gui/v3/src/lib/scenarioTemplates.ts @@ -0,0 +1,14 @@ +export type ScenarioTemplateId = 'empty' | 'figure8' | 'straight' | 'skidpad' + +export interface ScenarioTemplate { + id: ScenarioTemplateId + label: string + hint: string +} + +export const SCENARIO_TEMPLATES: ScenarioTemplate[] = [ + { id: 'empty', label: 'Empty', hint: 'Flat road · short path · 1 vehicle' }, + { id: 'figure8', label: 'Figure-8', hint: 'Default track · v=10 m/s' }, + { id: 'straight', label: 'Straight', hint: '±40 m line · v=15 m/s' }, + { id: 'skidpad', label: 'Skidpad', hint: 'R=40 m circle · v=12 m/s' }, +] diff --git a/gui/v3/src/lib/schematic/linkageSvg.ts b/gui/v3/src/lib/schematic/linkageSvg.ts new file mode 100644 index 00000000..f68bb392 --- /dev/null +++ b/gui/v3/src/lib/schematic/linkageSvg.ts @@ -0,0 +1,68 @@ +import type { SuspensionSchematic } from '../api/catalog' + +function yzPoints(sch: SuspensionSchematic): [number, number][] { + const pts: [number, number][] = [] + for (const [a, b] of sch.links) { + pts.push([a[1], a[2]], [b[1], b[2]]) + } + for (const p of Object.values(sch.points ?? {})) { + pts.push([p[1], p[2]]) + } + return pts +} + +export function linkageSvg( + sch: SuspensionSchematic, + opts?: { label?: string; stroke?: string }, +): string { + const pts = yzPoints(sch) + if (!pts.length) return '' + let ymin = pts[0][1] + let ymax = pts[0][1] + let zmin = pts[0][0] + let zmax = pts[0][0] + for (const [y, z] of pts) { + if (y < ymin) ymin = y + if (y > ymax) ymax = y + if (z < zmin) zmin = z + if (z > zmax) zmax = z + } + const pad = 0.08 + ymin -= pad + ymax += pad + zmin -= pad + zmax += pad + const spanY = Math.max(ymax - ymin, 0.2) + const spanZ = Math.max(zmax - zmin, 0.2) + const W = 200 + const H = 140 + const margin = 14 + const drawW = W - margin * 2 + const drawH = H - margin * 2 + const scale = Math.min(drawW / spanY, drawH / spanZ) + const cx = 0.5 * (ymin + ymax) + const cz = 0.5 * (zmin + zmax) + const visY = drawW / scale + const visZ = drawH / scale + const y0 = cx - visY * 0.5 + const z0 = cz - visZ * 0.5 + + const X = (y: number) => margin + (y - y0) * scale + const Y = (z: number) => H - margin - (z - z0) * scale + + const stroke = opts?.stroke ?? '#2f6d8f' + let svg = `` + svg += `` + for (const [a, b] of sch.links) { + svg += `` + } + for (const [lab, p] of Object.entries(sch.points ?? {})) { + svg += `` + svg += `${lab}` + } + if (opts?.label) { + svg += `${opts.label}` + } + svg += '' + return svg +} diff --git a/gui/v3/src/lib/setupStore.svelte.ts b/gui/v3/src/lib/setupStore.svelte.ts new file mode 100644 index 00000000..9da54f0a --- /dev/null +++ b/gui/v3/src/lib/setupStore.svelte.ts @@ -0,0 +1,306 @@ +import { + addFleetVehicle, + DEG2RAD, + applyScenarioTemplate as apiApplyTemplate, + getPathPoints, + getSetup, + loadScenario, + postSetup, + roadBankDeg, + roadGradeDeg, + saveScenario, + startRun, +} from './api/setup' +import type { FleetAgent, Setup, SetupPatch } from './api/types' + +export const setupStore = $state({ + setup: null as Setup | null, + pathPts: [] as [number, number][], + dirty: false, + busy: false, + error: null as string | null, + loadedScenario: '', + selectedVid: 0, +}) + +export function scenarios(): string[] { + return setupStore.setup?.scenarios ?? setupStore.setup?.scenes ?? [] +} + +export async function refreshSetup(): Promise { + setupStore.busy = true + setupStore.error = null + try { + const s = await getSetup() + setupStore.setup = s + setupStore.pathPts = + s.path_pts?.length ? s.path_pts.map((p) => [p[0], p[1]] as [number, number]) : await getPathPoints() + if (s.fleet?.length) { + const ids = s.fleet.map((f) => f.id) + if (!ids.includes(setupStore.selectedVid)) { + setupStore.selectedVid = s.fleet[0].id + } + } + setupStore.dirty = false + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + } finally { + setupStore.busy = false + } +} + +export function selectedAgent(): FleetAgent | null { + return setupStore.setup?.fleet.find((f) => f.id === setupStore.selectedVid) ?? null +} + +function defaultRoadPatch(): SetupPatch['road'] { + const r = setupStore.setup?.road + return { + mu: r?.mu ?? 1, + grade: r?.grade ?? 0, + bank: r?.bank ?? 0, + mu_right: r?.mu_right ?? -1, + mu_boundary: r?.mu_boundary ?? 0, + rough_amp: r?.rough_amp ?? 0, + rough_wl: r?.rough_wl ?? 4, + } +} + +export function buildPayload(): SetupPatch { + const s = setupStore.setup + return { + path_preset: s?.path_preset ?? 'figure8', + fleet: s?.fleet ?? [], + road: defaultRoadPatch(), + v_target: s?.v_target ?? 10, + } +} + +export async function syncDraft(): Promise { + setupStore.busy = true + setupStore.error = null + try { + const j = await postSetup(buildPayload()) + if (j.setup) setupStore.setup = { ...setupStore.setup!, ...j.setup, fleet: j.setup.fleet ?? setupStore.setup!.fleet } + const pts = setupStore.setup?.path_pts + setupStore.pathPts = + pts?.length ? pts.map((p) => [p[0], p[1]] as [number, number]) : await getPathPoints() + setupStore.dirty = false + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } finally { + setupStore.busy = false + } +} + +export async function patchSetup(patch: SetupPatch): Promise { + setupStore.busy = true + setupStore.error = null + try { + const j = await postSetup(patch) + if (j.setup) { + setupStore.setup = { + ...setupStore.setup!, + ...j.setup, + fleet: j.setup.fleet ?? setupStore.setup!.fleet, + road: { ...setupStore.setup!.road, ...j.setup.road }, + } + } + const pts = setupStore.setup?.path_pts + setupStore.pathPts = + pts?.length ? pts.map((p) => [p[0], p[1]] as [number, number]) : await getPathPoints() + setupStore.dirty = false + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } finally { + setupStore.busy = false + } +} + +export function markDirty(): void { + setupStore.dirty = true +} + +export function setMu(mu: number): void { + if (!setupStore.setup) return + setupStore.setup.road.mu = mu + markDirty() +} + +export function setGradeDeg(deg: number): void { + if (!setupStore.setup) return + setupStore.setup.road.grade = deg * DEG2RAD + markDirty() +} + +export function setBankDeg(deg: number): void { + if (!setupStore.setup) return + setupStore.setup.road.bank = deg * DEG2RAD + markDirty() +} + +export function setVTarget(v: number): void { + if (!setupStore.setup) return + setupStore.setup.v_target = v + markDirty() +} + +export async function setPathPreset(preset: string): Promise { + if (!setupStore.setup) return + setupStore.setup.path_preset = preset + await patchSetup({ path_preset: preset }) +} + +let pathCommitTimer: ReturnType | undefined + +export function setLocalPathPts(path_pts: [number, number][]): void { + setupStore.pathPts = path_pts + if (setupStore.setup) setupStore.setup.path_preset = 'custom' + markDirty() +} + +export async function commitCustomPath(path_pts: [number, number][]): Promise { + setLocalPathPts(path_pts) + clearTimeout(pathCommitTimer) + pathCommitTimer = setTimeout(async () => { + await patchSetup({ path_preset: 'custom', path_pts }) + }, 180) +} + +export async function moveFleetAgent(vid: number, x0: number, y0: number): Promise { + if (!setupStore.setup) return + const fleet = setupStore.setup.fleet.map((f) => + f.id === vid ? { ...f, x0, y0 } : f, + ) + setupStore.setup.fleet = fleet + await patchSetup({ fleet: [{ id: vid, x0, y0 }] }) +} + +export async function applyScenarioTemplate(template: string): Promise { + if ( + setupStore.dirty && + !confirm('Unsaved draft changes will be lost. Apply template?') + ) { + return false + } + setupStore.busy = true + setupStore.error = null + try { + const j = await apiApplyTemplate(template) + if (!j.ok) throw new Error(j.error || 'template failed') + setupStore.loadedScenario = '' + if (j.setup) { + setupStore.setup = { ...setupStore.setup!, ...j.setup, fleet: j.setup.fleet ?? [] } + setupStore.pathPts = + j.setup.path_pts?.length + ? j.setup.path_pts.map((p) => [p[0], p[1]] as [number, number]) + : await getPathPoints() + if (j.setup.fleet?.length) setupStore.selectedVid = j.setup.fleet[0].id + } else { + await refreshSetup() + } + setupStore.dirty = false + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } finally { + setupStore.busy = false + } +} + +export async function loadScenarioByName(name: string): Promise { + if (!name) return false + if (setupStore.dirty && !confirm('Unsaved draft changes will be lost. Load scenario?')) { + return false + } + setupStore.busy = true + try { + const j = await loadScenario(name) + if (!j.ok) throw new Error(j.error || 'load failed') + setupStore.loadedScenario = name + if (j.live_vid != null) setupStore.selectedVid = j.live_vid + await refreshSetup() + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } finally { + setupStore.busy = false + } +} + +export async function saveScenarioDraft(name: string, overwrite = false): Promise { + if (!name.trim()) return false + const ok = await syncDraft() + if (!ok) return false + try { + const j = await saveScenario(name.trim(), overwrite) + if (!j.ok) throw new Error(j.error || 'save failed') + setupStore.loadedScenario = j.name ?? name + await refreshSetup() + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } +} + +export async function addVehicle(): Promise { + const j = await addFleetVehicle() + if (j.setup?.fleet) { + setupStore.setup = { ...setupStore.setup!, fleet: j.setup.fleet } + const last = j.setup.fleet[j.setup.fleet.length - 1] + if (last) setupStore.selectedVid = last.id + setupStore.pathPts = await getPathPoints() + } +} + +export async function patchFleet(vid: number, patch: Partial): Promise { + return patchSetup({ fleet: [{ id: vid, ...patch }] }) +} + +export async function setFleetBlueprint( + vid: number, + blueprintId: string, + opts?: { confirm?: boolean }, +): Promise { + const agent = setupStore.setup?.fleet.find((f) => f.id === vid) + if (agent?.blueprint === blueprintId) return true + if ( + opts?.confirm !== false && + agent?.blueprint && + !confirm('Changing blueprint replaces catalog parts for this vehicle. Continue?') + ) { + return false + } + return patchFleet(vid, { blueprint: blueprintId }) +} + +export async function setFleetPart(vid: number, slot: string, partId: string): Promise { + return patchFleet(vid, { parts: { [slot]: partId } }) +} + +export async function setFleetLevel(vid: number, level: string): Promise { + return patchFleet(vid, { level }) +} + +export async function playSimulation(): Promise { + if (!setupStore.setup) return false + const ok = await syncDraft() + if (!ok) return false + try { + const j = await startRun() + if (j.ok === false) throw new Error(j.error || 'start failed') + return true + } catch (e) { + setupStore.error = e instanceof Error ? e.message : String(e) + return false + } +} + +export { roadGradeDeg, roadBankDeg } diff --git a/gui/v3/src/lib/vendor/orbitControls.d.ts b/gui/v3/src/lib/vendor/orbitControls.d.ts new file mode 100644 index 00000000..bf87ae89 --- /dev/null +++ b/gui/v3/src/lib/vendor/orbitControls.d.ts @@ -0,0 +1,4 @@ +declare module '@vdsim/vendor-orbit-controls' { + import type { OrbitControls as OC } from 'three/examples/jsm/controls/OrbitControls.js' + export const OrbitControls: typeof OC +} diff --git a/gui/v3/src/lib/viewport/RunViewport.ts b/gui/v3/src/lib/viewport/RunViewport.ts new file mode 100644 index 00000000..20344cb9 --- /dev/null +++ b/gui/v3/src/lib/viewport/RunViewport.ts @@ -0,0 +1,264 @@ +import * as THREE from 'three' +import { OrbitControls } from '@vdsim/vendor-orbit-controls' +import { applySteer, buildCarMesh, spinWheels, type CarMesh } from './carMesh' +import { lerpAngle, toThree, vdsimIsoToThreeQuat } from './coords' +import type { SimStreamState } from './types' +import { pickVehicleState } from './types' +import { fetchVehicleGeom } from './vehicleGeom' +import { disposeWheelForces, hideWheelForces, updateWheelForces } from './wheelForces' + +export type CamMode = 'chase' | 'orbit' | 'top' + +export class RunViewport { + private scene = new THREE.Scene() + private camera = new THREE.PerspectiveCamera(55, 1, 0.1, 2000) + private ortho = new THREE.OrthographicCamera(-35, 35, 35, -35, 0.1, 2000) + private renderer: THREE.WebGLRenderer + private controls: InstanceType + private clock = new THREE.Clock() + private raf = 0 + private car: CarMesh | null = null + private carLevel = '' + private liveVid = 0 + private camMode: CamMode = 'chase' + private running = false + private showForces = true + private tgt = { + has: false, + pos: new THREE.Vector3(), + yaw: 0, + roll: 0, + pitch: 0, + vx: 0, + vy: 0, + r: 0, + wheelSpin: [0, 0, 0, 0] as number[], + Ft: [] as number[][], + Fz: [] as number[], + } + private pathLine: THREE.Line | null = null + private pathCount = 0 + private onState: ((s: SimStreamState) => void) | null = null + + constructor(private mount: HTMLElement) { + this.scene.background = new THREE.Color(0xe9eef3) + this.camera.position.set(15, 14, 22) + this.ortho.position.set(0, 120, 0) + this.ortho.up.set(0, 0, -1) + this.ortho.lookAt(0, 0, 0) + + this.renderer = new THREE.WebGLRenderer({ antialias: true }) + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + this.mount.appendChild(this.renderer.domElement) + + this.controls = new OrbitControls(this.camera, this.renderer.domElement) + this.controls.minDistance = 4 + this.controls.maxDistance = 480 + + this.scene.add(new THREE.HemisphereLight(0xffffff, 0x9aa6b2, 1.15)) + const dir = new THREE.DirectionalLight(0xffffff, 0.8) + dir.position.set(20, 40, 15) + this.scene.add(dir) + this.scene.add(new THREE.GridHelper(120, 60, 0xb6c2cf, 0xd2dae3)) + + this.resize() + const ro = new ResizeObserver(() => this.resize()) + ro.observe(mount) + this.mount.dataset.ro = '1' + + this.loop() + void this.loadPath() + } + + setCamMode(m: CamMode) { + this.camMode = m + this.controls.enabled = m === 'orbit' + } + + setOnState(cb: (s: SimStreamState) => void) { + this.onState = cb + } + + setShowForces(on: boolean) { + this.showForces = on + if (!on && this.car) hideWheelForces(this.car.forces) + } + + async applyState(s: SimStreamState) { + this.running = !!(s.running && !s.setup_mode) + const vid = s.live_vid ?? 0 + this.liveVid = vid + const preview = !!(s.setup_mode || !s.running) + const st = pickVehicleState(s, vid) + + if (preview) { + if (this.car) hideWheelForces(this.car.forces) + const level = st.level ?? s.level ?? 'L2' + if (!this.car || this.carLevel !== level) { + await this.rebuildCar(level, vid) + } + if (this.car && st.x != null) { + this.tgt.pos.copy(toThree(st.x, st.y ?? 0, st.z ?? 0)) + this.tgt.yaw = st.yaw ?? 0 + this.tgt.roll = 0 + this.tgt.pitch = 0 + this.tgt.has = true + } + const npath = s.npath + if (npath != null && npath !== this.pathCount) { + this.pathCount = npath + void this.loadPath() + } + this.onState?.(s) + return + } + + const level = st.level ?? s.level ?? 'L2' + if (!this.car || this.carLevel !== level) { + await this.rebuildCar(level, vid) + } + + if (st.x == null || !this.car) { + this.onState?.(s) + return + } + + this.tgt.pos.copy(toThree(st.x, st.y ?? 0, st.z ?? 0)) + this.tgt.yaw = st.yaw ?? 0 + const dyn3 = level === 'L3' || level === 'L4' || level === 'L5' + this.tgt.roll = dyn3 ? (st.roll ?? 0) : 0 + this.tgt.pitch = dyn3 ? (st.pitch ?? 0) : 0 + this.tgt.vx = st.vx ?? 0 + this.tgt.vy = st.vy ?? 0 + this.tgt.r = st.r ?? 0 + this.tgt.wheelSpin = st.wheel_spin ?? [0, 0, 0, 0] + this.tgt.Ft = st.Ft ?? [] + this.tgt.Fz = st.Fz ?? [] + this.tgt.has = true + + if (st.steer != null) applySteer(this.car.wheels, this.car.steerIdx, st.steer) + this.onState?.(s) + } + + private async rebuildCar(level: string, vid: number) { + if (this.car) { + disposeWheelForces(this.car.forces) + this.scene.remove(this.car.group) + this.car.group.traverse((o: THREE.Object3D) => { + const m = o as THREE.Mesh + if (m.geometry) m.geometry.dispose() + }) + } + const geom = await fetchVehicleGeom(vid) + this.car = buildCarMesh(geom, level, vid) + this.carLevel = level + this.scene.add(this.car.group) + } + + private async loadPath() { + try { + const r = await fetch('/api/path') + const j = await r.json() + const pts = (j.pts ?? []) as [number, number][] + if (pts.length < 2) return + const v = pts.map((p) => toThree(p[0], p[1], 0.15)) + if (this.pathLine) { + this.scene.remove(this.pathLine) + this.pathLine.geometry.dispose() + } + const mat = new THREE.LineBasicMaterial({ color: 0x2f6d8f }) + this.pathLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(v), mat) + this.scene.add(this.pathLine) + this.pathCount = pts.length + } catch { + /* optional */ + } + } + + private resize() { + const w = this.mount.clientWidth + const h = this.mount.clientHeight + if (w < 1 || h < 1) return + this.camera.aspect = w / h + this.camera.updateProjectionMatrix() + const aspect = w / h + const s = 70 + this.ortho.left = (-s / 2) * aspect + this.ortho.right = (s / 2) * aspect + this.ortho.top = s / 2 + this.ortho.bottom = -s / 2 + this.ortho.updateProjectionMatrix() + this.renderer.setSize(w, h, false) + } + + private loop = () => { + this.raf = requestAnimationFrame(this.loop) + const dt = Math.min(this.clock.getDelta(), 0.1) + const car = this.car + + if (car && this.tgt.has) { + if (this.running) { + const yaw = this.tgt.yaw + const vE = this.tgt.vx * Math.cos(yaw) - this.tgt.vy * Math.sin(yaw) + const vN = this.tgt.vx * Math.sin(yaw) + this.tgt.vy * Math.cos(yaw) + car.group.position.x += vE * dt + car.group.position.z += -vN * dt + car.group.position.lerp(this.tgt.pos, 0.1) + vdsimIsoToThreeQuat( + this.tgt.roll, + this.tgt.pitch, + lerpAngle(yaw + this.tgt.r * dt, this.tgt.yaw, 0.15), + car.group.quaternion, + ) + spinWheels(car.wheels, car.wheelRoll, this.tgt.wheelSpin, dt) + updateWheelForces(car, car.forces, this.tgt.Ft, this.tgt.Fz, this.showForces) + } else { + car.group.position.copy(this.tgt.pos) + vdsimIsoToThreeQuat(0, 0, this.tgt.yaw, car.group.quaternion) + hideWheelForces(car.forces) + } + + const cp = car.group.position + if (this.camMode === 'chase') { + const fx = Math.cos(this.tgt.yaw) + const fz = -Math.sin(this.tgt.yaw) + const chase = new THREE.Vector3(cp.x - fx * 11, cp.y + 5, cp.z - fz * 11) + if (this.running) this.camera.position.lerp(chase, 0.12) + else this.camera.position.copy(chase) + this.camera.lookAt(cp.x, cp.y + 1.2, cp.z) + } else if (this.camMode === 'top') { + this.ortho.position.set(cp.x, 120, cp.z) + this.ortho.lookAt(cp.x, 0, cp.z) + } else if (this.running) { + this.controls.target.lerp(cp, 0.15) + this.controls.update() + } else { + this.controls.target.copy(cp) + this.controls.update() + } + } + + const active = this.running && this.camMode === 'top' ? this.ortho : this.camera + this.renderer.render(this.scene, active) + } + + dispose() { + cancelAnimationFrame(this.raf) + this.controls.dispose() + this.renderer.dispose() + if (this.renderer.domElement.parentElement === this.mount) { + this.mount.removeChild(this.renderer.domElement) + } + if (this.pathLine) { + this.pathLine.geometry.dispose() + ;(this.pathLine.material as THREE.Material).dispose() + } + if (this.car) { + disposeWheelForces(this.car.forces) + this.car.group.traverse((o: THREE.Object3D) => { + const m = o as THREE.Mesh + if (m.geometry) m.geometry.dispose() + }) + } + } +} diff --git a/gui/v3/src/lib/viewport/carMesh.ts b/gui/v3/src/lib/viewport/carMesh.ts new file mode 100644 index 00000000..4a52ed33 --- /dev/null +++ b/gui/v3/src/lib/viewport/carMesh.ts @@ -0,0 +1,120 @@ +import * as THREE from 'three' +import type { VehicleGeom } from './vehicleGeom' +import { attachWheelForces, type WheelForceArrows } from './wheelForces' + +const FLEET_COLORS = [0x01a0e9, 0xdc291e, 0x34c759, 0xf5a623] +const FLEET_CABIN = [0x9fd8f5, 0xf5a8a4, 0xb8f0c8, 0xffe0b0] + +export interface CarMesh { + group: THREE.Group + wheels: THREE.Object3D[] + wheelRoll: number[] + steerIdx: number[] + level: string + forces: WheelForceArrows +} + +function makeWheel(R: number): THREE.Group { + const spin = new THREE.Group() + spin.rotation.order = 'YXZ' + const w = new THREE.Mesh( + new THREE.CylinderGeometry(R, R, 0.22, 14), + new THREE.MeshStandardMaterial({ color: 0x14181f }), + ) + w.rotation.x = Math.PI / 2 + spin.add(w) + return spin +} + +export function buildCarMesh(geom: VehicleGeom, level: string, vid: number): CarMesh { + const grp = new THREE.Group() + grp.rotation.order = 'YXZ' + grp.userData.vid = vid + + const a = geom.cg_to_front + const b = geom.cg_to_rear + const tf = geom.track_front + const tr = geom.track_rear + const R = geom.wheel_radius_nominal + const bike = level === 'K' || level === 'L1' + const L = a + b + const W = Math.max(tf, tr, 0.6) + const bodyH = 0.5 + const bodyY = R + 0.45 + const bodyW = bike ? 0.45 : Math.max(0.4, W - 0.5) + + const bm = new THREE.MeshStandardMaterial({ + color: FLEET_COLORS[vid % FLEET_COLORS.length], + metalness: 0.3, + roughness: 0.5, + }) + const cm = new THREE.MeshStandardMaterial({ + color: FLEET_CABIN[vid % FLEET_CABIN.length], + metalness: 0.25, + roughness: 0.55, + }) + + const body = new THREE.Mesh(new THREE.BoxGeometry(L * 0.78, bodyH, bodyW), bm) + body.position.set((a - b) / 2, bodyY, 0) + grp.add(body) + + const cabin = new THREE.Mesh( + new THREE.BoxGeometry(L * 0.34, 0.38, bike ? 0.34 : Math.max(0.3, W * 0.55)), + cm, + ) + cabin.position.set((a - b) / 2 - 0.15, bodyY + bodyH / 2 + 0.19, 0) + grp.add(cabin) + + const pos: [number, number][] = bike + ? [[a, 0], [-b, 0]] + : [ + [a, -tf / 2], + [a, tf / 2], + [-b, -tr / 2], + [-b, tr / 2], + ] + + const wheels: THREE.Object3D[] = [] + for (const [x, z] of pos) { + const w = makeWheel(R) + w.position.set(x, R, z) + grp.add(w) + wheels.push(w) + } + + const mesh: CarMesh = { + group: grp, + wheels, + wheelRoll: wheels.map(() => 0), + steerIdx: bike ? [0] : [0, 1], + level, + forces: { axX: [], axY: [], axZ: [] }, + } + mesh.forces = attachWheelForces(mesh) + return mesh +} + +export function applySteer(wheels: THREE.Object3D[], steerIdx: number[], steer: number) { + for (const i of steerIdx) { + if (wheels[i]) wheels[i].rotation.y = steer + } +} + +export function spinWheels( + wheels: THREE.Object3D[], + wheelRoll: number[], + spin: number[], + dt: number, +) { + while (wheelRoll.length < wheels.length) wheelRoll.push(0) + for (let i = 0; i < wheels.length; i++) { + const rate = + wheels.length === 2 + ? i === 0 + ? ((spin[0] ?? 0) + (spin[1] ?? 0)) * 0.5 + : ((spin[2] ?? 0) + (spin[3] ?? 0)) * 0.5 + : (spin[i] ?? 0) + wheelRoll[i] -= rate * dt + wheels[i].rotation.z = wheelRoll[i] + } +} diff --git a/gui/v3/src/lib/viewport/coords.ts b/gui/v3/src/lib/viewport/coords.ts new file mode 100644 index 00000000..2607b23b --- /dev/null +++ b/gui/v3/src/lib/viewport/coords.ts @@ -0,0 +1,18 @@ +import * as THREE from 'three' + +const _q = new THREE.Quaternion() + +export function toThree(x: number, y: number, z = 0): THREE.Vector3 { + return new THREE.Vector3(x, z, -y) +} + +export function vdsimIsoToThreeQuat(roll: number, pitch: number, yaw: number, out = _q): THREE.Quaternion { + const e = new THREE.Euler(roll, yaw, -pitch, 'ZYX') + return out.setFromEuler(e) +} + +export function lerpAngle(a: number, b: number, t: number): number { + let d = ((b - a + Math.PI) % (2 * Math.PI)) - Math.PI + if (d < -Math.PI) d += 2 * Math.PI + return a + d * t +} diff --git a/gui/v3/src/lib/viewport/types.ts b/gui/v3/src/lib/viewport/types.ts new file mode 100644 index 00000000..b4f77dbf --- /dev/null +++ b/gui/v3/src/lib/viewport/types.ts @@ -0,0 +1,44 @@ +export interface SimStreamState { + t?: number + running?: boolean + setup_mode?: boolean + paused?: boolean + driver?: boolean + live_vid?: number + level?: string + x?: number + y?: number + z?: number + yaw?: number + roll?: number + pitch?: number + vx?: number + vy?: number + r?: number + ax?: number + ay?: number + steer?: number + throttle_applied?: number + brake_applied?: number + wheel_spin?: number[] + kappa?: number[] + alpha?: number[] + Fz?: number[] + Ft?: number[][] + rack_torque?: number + time_scale?: number + plant_error?: string + kinematics_warnings?: string[] + fleet?: Record> + fleet_spec?: { id: number; level?: string }[] + fleet_cmd?: Record + cmd_in?: { throttle?: number; brake?: number; steer?: number } + npath?: number +} + +export function pickVehicleState(s: SimStreamState, vid: number): SimStreamState { + const f = s.fleet?.[String(vid)] + if (f && f.x != null) return { ...s, ...f } + if (s.live_vid === vid || vid === 0) return s + return f ? { ...s, ...f } : s +} diff --git a/gui/v3/src/lib/viewport/vehicleGeom.ts b/gui/v3/src/lib/viewport/vehicleGeom.ts new file mode 100644 index 00000000..0892dd43 --- /dev/null +++ b/gui/v3/src/lib/viewport/vehicleGeom.ts @@ -0,0 +1,34 @@ +export interface VehicleGeom { + cg_to_front: number + cg_to_rear: number + track_front: number + track_rear: number + wheel_radius_nominal: number +} + +const DEFAULT_GEOM: VehicleGeom = { + cg_to_front: 1.25, + cg_to_rear: 1.55, + track_front: 1.55, + track_rear: 1.55, + wheel_radius_nominal: 0.33, +} + +const cache: Record = {} + +export async function fetchVehicleGeom(vid: number): Promise { + const k = String(vid) + if (cache[k]) return cache[k] + try { + const r = await fetch(`/api/vehicle?vehicle_id=${vid}`) + const j = await r.json() + const g = { ...DEFAULT_GEOM } + for (const f of j.fields ?? []) { + if (f.name in g) (g as Record)[f.name] = f.value + } + cache[k] = g + return g + } catch { + return { ...DEFAULT_GEOM } + } +} diff --git a/gui/v3/src/lib/viewport/wheelForces.ts b/gui/v3/src/lib/viewport/wheelForces.ts new file mode 100644 index 00000000..e5c46f23 --- /dev/null +++ b/gui/v3/src/lib/viewport/wheelForces.ts @@ -0,0 +1,109 @@ +import * as THREE from 'three' +import type { CarMesh } from './carMesh' + +const FSCALE = 0.0012 +const FZSCALE = 0.0006 +const HEAD = 0.3 + +export interface WheelForceArrows { + axX: THREE.Group[] + axY: THREE.Group[] + axZ: THREE.Group[] +} + +function makeForceArrow(parent: THREE.Object3D, x: number, z: number, color: number): THREE.Group { + const m = new THREE.MeshBasicMaterial({ color }) + const g = new THREE.Group() + const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1, 12), m) + shaft.position.y = 0.5 + const head = new THREE.Mesh(new THREE.ConeGeometry(0.14, 0.3, 16), m) + head.position.y = 1.0 + g.add(shaft, head) + g.position.set(x, 0.02, z) + g.visible = false + g.userData = { shaft, head } + parent.add(g) + return g +} + +export function attachWheelForces(car: CarMesh): WheelForceArrows { + const axX: THREE.Group[] = [] + const axY: THREE.Group[] = [] + const axZ: THREE.Group[] = [] + for (const w of car.wheels) { + const { x, z } = w.position + axX.push(makeForceArrow(car.group, x, z, 0xff3b30)) + axY.push(makeForceArrow(car.group, x, z, 0x34c759)) + axZ.push(makeForceArrow(car.group, x, z, 0x0a84ff)) + } + return { axX, axY, axZ } +} + +function setArrow(ar: THREE.Group, dir: THREE.Vector3, mag: number, scale: number) { + if (mag <= 5) { + ar.visible = false + return + } + ar.visible = true + const L = Math.max(0.18, mag * scale) + const shaft = ar.userData.shaft as THREE.Mesh + const head = ar.userData.head as THREE.Mesh + ar.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()) + const sLen = Math.max(0.02, L - HEAD) + shaft.scale.y = sLen + shaft.position.y = sLen / 2 + head.position.y = sLen + HEAD / 2 +} + +export function updateWheelForces( + car: CarMesh, + forces: WheelForceArrows, + Ft: number[][] | undefined, + Fz: number[] | undefined, + visible: boolean, +) { + const { wheels, level } = car + if (!visible || !wheels.length || level === 'K') { + hideWheelForces(forces) + return + } + const fz = Fz ?? [0, 0, 0, 0] + const ft = (i: number) => (Ft && Ft[i] ? Ft[i] : [0, 0]) + const bike = wheels.length === 2 + const wf: [number, number, number][] = bike + ? [ + [ft(0)[0] + ft(1)[0], ft(0)[1] + ft(1)[1], (fz[0] || 0) + (fz[1] || 0)], + [ft(2)[0] + ft(3)[0], ft(2)[1] + ft(3)[1], (fz[2] || 0) + (fz[3] || 0)], + ] + : [0, 1, 2, 3].map((i) => [ft(i)[0], ft(i)[1], fz[i] || 0]) + + const Y = new THREE.Vector3(0, 1, 0) + for (let i = 0; i < wheels.length; i++) { + const [Fx, Fy, Fzz] = wf[i] + const w = wheels[i].position + ;[forces.axX[i], forces.axY[i], forces.axZ[i]].forEach((a) => a.position.set(w.x, 0.02, w.z)) + setArrow(forces.axZ[i], Y, Fzz, FZSCALE) + if (bike) { + forces.axX[i].visible = false + forces.axY[i].visible = false + } else { + setArrow(forces.axX[i], new THREE.Vector3(Math.sign(Fx) || 1, 0, 0), Math.abs(Fx), FSCALE) + setArrow(forces.axY[i], new THREE.Vector3(0, 0, -(Math.sign(Fy) || 1)), Math.abs(Fy), FSCALE) + } + } +} + +export function hideWheelForces(forces: WheelForceArrows) { + for (const ar of [...forces.axX, ...forces.axY, ...forces.axZ]) ar.visible = false +} + +export function disposeWheelForces(forces: WheelForceArrows) { + for (const ar of [...forces.axX, ...forces.axY, ...forces.axZ]) { + ar.parent?.remove(ar) + ar.traverse((o) => { + const m = o as THREE.Mesh + if (m.geometry) m.geometry.dispose() + if (m.material) (m.material as THREE.Material).dispose() + }) + } +} diff --git a/gui/v3/src/main.ts b/gui/v3/src/main.ts new file mode 100644 index 00000000..fcf89224 --- /dev/null +++ b/gui/v3/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { target: document.getElementById('app')! }) + +export default app diff --git a/gui/v3/src/modes/analyze/AnalyzeMode.svelte b/gui/v3/src/modes/analyze/AnalyzeMode.svelte new file mode 100644 index 00000000..ad16eb2b --- /dev/null +++ b/gui/v3/src/modes/analyze/AnalyzeMode.svelte @@ -0,0 +1,412 @@ + + +
+
+ ISO compare +
+ {#each COMPARE_MANEUVERS as m (m.id)} + + {/each} +
+ + + + {#if result} + + {/if} + ← Scenario +
+ + {#if err} +
{err}
+ {/if} + +
+ Vehicles + {#if !picks.length} + Add fleet agents in Scenario first. + {:else} + {#each picks as p (p.id)} + + {/each} + {/if} +
+ +
+
+ {#if chartSvg} +

Step-steer yaw-rate overlay

+
{@html chartSvg}
+ {:else if result && !Object.keys(result.traces ?? {}).length} +

Include Step steer maneuver for yaw-rate chart.

+ {:else if busy} +

Running ISO maneuvers…

+ {:else} +

Select ≥2 vehicles and run compare.

+ {/if} +
+ +
+ {#if result?.rows?.length} +

Metrics · Δ% vs first column (baseline)

+
+ + + + + {#each result.rows as row, i} + + {/each} + + + + {#each result.columns as col} + {@const base = result.rows[0]?.[col]} + + + {#each result.rows as row, i} + {@const v = row[col]} + + {/each} + + {/each} + +
metric + {vehicleLabel(row.vehicle)} + {#if i === 0}base{/if} +
{col} + {#if typeof v === 'number'} + {cmpFmt(v)} + {#if i > 0} + {@const d = cmpDeltaPct(base, v)} + {#if d} + {d.text} + {/if} + {/if} + {:else} + {v ?? ''} + {/if} +
+
+ {:else} +

Δ% table appears after compare run.

+ {/if} +
+
+
+ + diff --git a/gui/v3/src/modes/run/RunMode.svelte b/gui/v3/src/modes/run/RunMode.svelte new file mode 100644 index 00000000..c7aed163 --- /dev/null +++ b/gui/v3/src/modes/run/RunMode.svelte @@ -0,0 +1,332 @@ + + +
+
+
+ + + + | + + | + + + | + + + + {#if running} + ● running + {#if tel?.t != null} + t = {tel.t.toFixed(2)} s + {/if} + {:else} + setup + {/if} +
+
+ + {#if !driver && running} +
+ + + onTouchSteer(touchSteer)} + /> + ↑↓←→ keys +
+ {/if} +
+ {#if status} +
{status}
+ {/if} +
+ +
+ + diff --git a/gui/v3/src/modes/scenario/ScenarioMode.svelte b/gui/v3/src/modes/scenario/ScenarioMode.svelte new file mode 100644 index 00000000..ead02f3e --- /dev/null +++ b/gui/v3/src/modes/scenario/ScenarioMode.svelte @@ -0,0 +1,447 @@ + + +{#if setupStore.error} +
{setupStore.error}
+{/if} + +{#if setupStore.setup} + {@const s = setupStore.setup} +
+
+
+ + + + + +
+ {#if pathEditActive} +
Drag ● waypoints · click to add · Del remove · Esc done
+ {/if} +
+ { setupStore.selectedVid = vid }} + onMove={(vid, x, y) => moveFleetAgent(vid, x, y)} + onPathChange={onPathPtsChange} + onPathEditEnd={() => { pathEditActive = false }} + /> +
+
+ +
+{:else if setupStore.busy} +

Loading setup…

+{:else} +

No API — start python3 gui/server.py --port 8095

+{/if} + + diff --git a/gui/v3/src/tokens.css b/gui/v3/src/tokens.css new file mode 100644 index 00000000..22f86046 --- /dev/null +++ b/gui/v3/src/tokens.css @@ -0,0 +1,21 @@ +:root { + --bg: #f7f8fa; + --surface: #ffffff; + --border: #e2e5eb; + --ink: #1a1d24; + --dim: #6b7280; + --acc: #01a0e9; + --acc-dim: #0188c7; + --ok: #16a34a; + --warn: #f59e0b; + --err: #dc2626; + --font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --fs-xs: 10px; + --fs-sm: 11px; + --fs-md: 13px; + --fs-lg: 15px; + --radius: 6px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.06); + --nav-h: 44px; + --status-h: 28px; +} diff --git a/gui/v3/svelte.config.js b/gui/v3/svelte.config.js new file mode 100644 index 00000000..0cf7db32 --- /dev/null +++ b/gui/v3/svelte.config.js @@ -0,0 +1,2 @@ +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default {} diff --git a/gui/v3/tsconfig.app.json b/gui/v3/tsconfig.app.json new file mode 100644 index 00000000..872fc6c7 --- /dev/null +++ b/gui/v3/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "module": "esnext", + "types": ["svelte", "vite/client"], + "noEmit": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": false, + "moduleDetection": "force", + "baseUrl": ".", + "paths": { + "@vdsim/vendor-orbit-controls": ["../vendor/three/addons/controls/OrbitControls.js"] + } + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/gui/v3/tsconfig.json b/gui/v3/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/gui/v3/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/gui/v3/tsconfig.node.json b/gui/v3/tsconfig.node.json new file mode 100644 index 00000000..d3c52ea6 --- /dev/null +++ b/gui/v3/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/gui/v3/vite.config.ts b/gui/v3/vite.config.ts new file mode 100644 index 00000000..b1f3e98f --- /dev/null +++ b/gui/v3/vite.config.ts @@ -0,0 +1,37 @@ +import path from 'node:path' +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + base: '/v3/', + resolve: { + alias: [ + { + find: '@vdsim/vendor-orbit-controls', + replacement: path.resolve( + __dirname, + '../vendor/three/addons/controls/OrbitControls.js', + ), + }, + { + find: 'three/examples/jsm/', + replacement: path.resolve(__dirname, '../vendor/three/addons/'), + }, + { + find: /^three$/, + replacement: path.resolve(__dirname, '../vendor/three/three.module.js'), + }, + ], + }, + build: { + outDir: 'dist', + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + '/api': { target: 'http://127.0.0.1:8095', changeOrigin: true }, + }, + }, +}) diff --git a/pyproject.toml b/pyproject.toml index 4ba122a9..0bbd183e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ plot = ["matplotlib", "pillow"] [project.scripts] vdsim-quickstart = "quickstart:main" +vdsim-render = "vdsim_render:main" [project.urls] Homepage = "https://onlyti.github.io/VDSim/" diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index d224b16c..4b8730f3 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -23,6 +23,8 @@ if(pybind11_FOUND) install(FILES ${CMAKE_SOURCE_DIR}/python/vdsim_lab.py ${CMAKE_SOURCE_DIR}/python/vdsim_plant.py + ${CMAKE_SOURCE_DIR}/python/vdsim_trace.py + ${CMAKE_SOURCE_DIR}/python/vdsim_render.py ${CMAKE_SOURCE_DIR}/cosim/protocol.py ${CMAKE_SOURCE_DIR}/examples/opendrive.py ${CMAKE_SOURCE_DIR}/examples/rd5_route.py diff --git a/python/VDSIM_PLANT_README.md b/python/VDSIM_PLANT_README.md new file mode 100644 index 00000000..bd552f2c --- /dev/null +++ b/python/VDSIM_PLANT_README.md @@ -0,0 +1,110 @@ +# vdsim_plant — VLA thesis closed-loop plant + +> **Full step-by-step tutorial:** [`docs/VDSIM_PLANT_TUTORIAL.md`](../docs/VDSIM_PLANT_TUTORIAL.md) +> (build → import → quickstart → obs schema → low-μ patches → closed-loop → YAML params → +> API → troubleshooting). This file is the terse quick-reference. + +Supported Python API for swapping VDSim in place of a dynamic-bicycle plant +(`from vdsim_plant import VDSimPlant`). MPC stays unchanged; only the plant is +replaced. + +## Install / import + +From the VDSim repo (after `cmake --build build -j`): + +```python +import sys +sys.path.insert(0, "/path/to/VDSim/build/python") +sys.path.insert(0, "/path/to/VDSim/python") +from vdsim_plant import VDSimPlant +``` + +Or `pip install` the wheel (includes `vdsim` + configs under `vdsim_configs`). + +## Quickstart + +```bash +PYTHONPATH=build/python:python python3 examples/vla_plant_demo.py +``` + +## API contract (pinned) + +```python +plant = VDSimPlant( + config="ioniq5_awd.yaml", # preset under configs/vehicles/ + friction_map=[(x0, x1, mu), ...], # optional x-interval patches + base_mu=0.9, + control_dt=0.05, # MPC sample (ZOH); must be >= substep_dt + substep_dt=5e-4, # internal integrator; must divide control_dt +) +plant.reset(state0=[X, Y, psi, vx, vy, r]) # wheel_spin = vx/R internally +obs = plant.step([delta_roadwheel_rad, Fx_total_N]) +``` + +### Observation dict (every step, true state, ISO 8855) + +| Key | Unit | Frame / note | +|-----|------|----------------| +| `X`, `Y` | m | world | +| `psi` | rad | yaw (+ left) | +| `vx`, `vy` | m/s | body | +| `r` | rad/s | yaw rate | +| `ax`, `ay` | m/s² | body estimated | +| `beta` | rad | sideslip | +| `wheel[i]` | — | i=0 FL, 1 FR, 2 RL, 3 RR | +| `wheel[i].Fx`, `Fy` | N | **contact / wheel frame** | +| `wheel[i].Fz` | N | normal load | +| `wheel[i].alpha`, `kappa` | rad, - | slip | +| `wheel[i].mu` | - | road-surface (contact) μ | +| `wheel[i].mu_peak` | - | realized tyre peak coeff at this Fz (capacity); `sat=‖F‖/(mu_peak·Fz)≤1` | +| `wheel[i].alpha_peak` | rad | slip angle at the lateral force peak (`inf`=no peak) | +| `wheel[i].kappa_peak` | - | slip ratio at the longitudinal force peak (`inf`=no peak) | + +Compare current `alpha`/`kappa` to `alpha_peak`/`kappa_peak` for rising vs sliding (drift) — see tutorial §4. + +Data-access path (read-only view, vehicle → part → physics): `plant.vehicle.params`, +`plant.vehicle.tire.params`, `plant.vehicle.tire.model` (live ITireModel; `plant.tire_model` is a shortcut). + +No usage / violation metrics in the plant (thesis-side only). + +## Preset: `ioniq5_awd` + +`configs/vehicles/ioniq5_awd.yaml` + `configs/parts/tire/ioniq5_pac2002.yaml` (+ `.tir`) +m=2359 kg, Iz=3400, lf=1.17 m, lr=1.80 m, μ_nom=0.9, AWD 50/50 +(`drive_split_front=0.5`). MF2002 `.tir` with load-dependent coefficients: a single tyre +recovers axle `Ca_f≈218k / Ca_r≈159k` from each axle's static Fz (`PKY1=-24.6, PKY2=2.56`), +cornering stiffness concave in load, peak μ falls with load (`PDY2=-0.10`). + +## Physics path + +- **Ld2 7DOF** (`create_seven_dof`), LuGre OFF, MF2002 `.tir` combined slip (load-dependent). +- **Fx_total → per-wheel torque** (CmdL1), bypassing throttle / drivetrain map. +- **Friction patches** via `create_friction_patch_ground` (per-wheel μ by contact x). +- **Plant path** bypasses vehicle kinematic low-speed blend (tyre VLOW only). +- **Deterministic** fixed `substep_dt`, no RNG. + +## Acceptance (run) + +```bash +cd build && ctest -R vla_plant --output-on-failure +``` + +1. Dry μ=0.9 lane change — qualitative yaw response. +2. **Headline:** brake+turn on μ=0.5 patch — friction saturation + slip + departure. +3. GT: ‖Fx,Fy‖ ≤ μ·Fz; ΣF ≈ m·a; bit-identical repeat. + +Typical 5 s trajectory ≪ 1 s wall-clock on a desktop CPU. + +## Drop-in snippet (sibling MPC project) + +```python +from vdsim_plant import VDSimPlant + +plant = VDSimPlant(config="ioniq5_awd.yaml", base_mu=0.9, + control_dt=0.1, substep_dt=1e-3) +plant.reset(state0=[0, 0, 0, 16.7, 0, 0]) +for k in range(N): + u = mpc.solve(...) # [delta, Fx] — unchanged + obs = plant.step(u) + # feed obs to estimator / logging +``` diff --git a/python/catalog/materialize.py b/python/catalog/materialize.py index 7fdcfa3c..e37c89f3 100644 --- a/python/catalog/materialize.py +++ b/python/catalog/materialize.py @@ -84,6 +84,10 @@ def fleet_spec_from_scene(doc: Mapping[str, Any]) -> List[dict]: "vehicle": vehicle_stem_from_blueprint(bid), "tire": tire_stem_from_id(parts.get("tire", "tire.default_pacejka")), } + # per-vehicle sensors: the scene declares them on the fleet entry; carry + # them through untouched so resolve_fleet_entry can put them on the world row. + if entry.get("sensors") is not None: + row["sensors"] = entry["sensors"] if level in ("L3", "L4"): bp_path = Path(__file__).resolve().parents[2] / "configs" / "blueprints" bp_name = bid.replace("vehicle.", "") + ".yaml" diff --git a/python/vdsim_plant.py b/python/vdsim_plant.py index 44d72f77..49dfc78d 100644 --- a/python/vdsim_plant.py +++ b/python/vdsim_plant.py @@ -12,10 +12,19 @@ alpha_peak [rad], kappa_peak [-] MF pure-slip peak slip at this Fz Built on vdsim_lab config resolution + SimSession (direct CmdL1 torque path). + +Trace recording (opt-in, default OFF) writes one ``.vdtrace`` per run:: + + plant.enable_trace("runs/r001/run.vdtrace") + ... # step() as usual + plant.finalize_trace() + +See :mod:`vdsim_trace` for the container contract. """ from __future__ import annotations import math +import subprocess import sys from pathlib import Path @@ -85,6 +94,86 @@ def _load_tire_setup_for_vehicle(vp_path: Path) -> vdsim.TireSetup: return vdsim.TireSetup(tp) +#: Record rate the default `decimation` targets [Hz]. 100 Hz is >3x a 30 fps +#: render and keeps 1 kHz control loops at a 10x storage saving. +TRACE_TARGET_HZ = 100.0 + + +def measure_mu_aniso(tp: vdsim.TireParams, Fz: float = None, n: int = 601): + """Measure the friction-ellipse mu multipliers of a tyre parameter set. + + The renderer needs ``util = sqrt((Fx/(k_lon*mu*Fz))^2 + (Fy/(k_lat*mu*Fz))^2)`` + but must not know the tyre model. So the producer measures the two + constants here, from pure-slip sweeps at unit road friction, and writes + them into the manifest: + + * ``k_lon = max_kappa |Fx| / Fz`` at ``alpha = 0`` + * ``k_lat = max_alpha |Fy| / Fz`` at ``kappa = 0`` + + Because ``mu_long = mu_lat = 1`` during the sweep, the result is a property + of the tyre model alone and is independent of the road friction map. + + :param tp: tyre parameters (``tir_path`` is used when the backend is MF2002). + :param Fz: sweep load [N]; defaults to the tyre's nominal load. + :param n: samples per sweep. + :returns: ``(friction_shape, [k_lon, k_lat])`` with ``friction_shape`` set + to ``"circle"`` when the two multipliers agree to 1 %, else ``"ellipse"``. + """ + Fz = float(Fz if Fz is not None else tp.Fz_nominal) + if not (Fz > 0.0): + raise ValueError("measure_mu_aniso needs Fz > 0") + if getattr(tp, "tir_path", ""): + model = vdsim.create_magic_formula_tire_from_tir(tp.tir_path) + else: + model = vdsim.create_pacejka_mf96(tp) + model.initialize(tp) + + def _peak(kappa_max, alpha_max, pick): + best = 0.0 + for i in range(n): + frac = i / float(n - 1) + inp = vdsim.TireInput() + inp.Fz = Fz + inp.kappa = kappa_max * frac + inp.alpha = alpha_max * frac + inp.mu_long = 1.0 + inp.mu_lat = 1.0 + inp.Vx_wheel = 16.0 + inp.gamma = 0.0 + best = max(best, abs(pick(model.compute(inp)))) + return best / Fz + + k_lon = _peak(1.0, 0.0, lambda o: o.Fx) + k_lat = _peak(0.0, 0.6, lambda o: o.Fy) + if k_lon <= 0.0 or k_lat <= 0.0: + raise ValueError("mu_aniso sweep produced a non-positive peak " + f"(k_lon={k_lon}, k_lat={k_lat})") + shape = "circle" if abs(k_lat - k_lon) <= 0.01 * k_lon else "ellipse" + return shape, [float(k_lon), float(k_lat)] + + +def _git_sha(repo: Path = REPO) -> str: + """Short git SHA of the working tree, or ``"unknown"`` outside a checkout.""" + try: + out = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5) + if out.returncode == 0: + return out.stdout.decode().strip() + except Exception: + pass + return "unknown" + + +def _vdsim_version() -> str: + """Installed distribution version, or ``"source"`` for an in-tree build.""" + try: + from importlib.metadata import version # py3.8+ + return version("vdsim") + except Exception: + return "source" + + def _obs_from_output(o: vdsim.SimOutput) -> dict: st = o.state wheels = [] @@ -286,6 +375,40 @@ def __init__( self._sess = vdsim.make_direct_control_session(self._vp, self._ts, sp, opts) self._dyn = self._sess.dynamics() + # Trace recording is opt-in; a plant that always records hands its + # regression to the customer. `_trace is None` is the OFF fast path. + self._trace = None + self._t = 0.0 + self._plant_params = { + "config": Path(vp_path).name, + "base_mu": float(base_mu), + "friction_map": [[float(a), float(b), float(m)] for a, b, m in (friction_map or [])], + "friction_map_2d": [ + {"polygon": [[float(x), float(y)] for x, y in e["polygon"]], + "mu": float(e["mu"])} + for e in (friction_map_2d or [])], + "substep_dt": float(substep_dt), + "integrator": "RK4", + "vehicle": { + "mass": float(self._vp.mass), + "wheelbase": float(self._vp.wheelbase), + "cg_to_front": float(self._vp.cg_to_front), + "cg_to_rear": float(self._vp.cg_to_rear), + "track_front": float(self._vp.track_front), + "track_rear": float(self._vp.track_rear), + "cg_height": float(self._vp.cg_height), + "wheel_radius_nominal": float(self._vp.wheel_radius_nominal), + "steering_ratio": float(self._vp.steering_ratio), + "drive_split_front": float(self._vp.drive_split_front), + }, + "tire": { + "backend": str(self._ts.wheel[0].backend), + "mu_nominal": float(self._ts.wheel[0].mu_nominal), + "Fz_nominal": float(self._ts.wheel[0].Fz_nominal), + "tir": Path(self._ts.wheel[0].tir_path).name if self._ts.wheel[0].tir_path else "", + }, + } + def reset(self, state0=None): """Seed pose [X, Y, psi, vx, vy, r]; wheel spin = vx/R (make_init_state).""" if state0 is None: @@ -298,6 +421,7 @@ def reset(self, state0=None): s0 = vdsim.make_init_state_6dof( self._vp, self._ts.wheel[0], x=x, y=y, yaw=psi, vx=vx, vy=vy, r=r) self._sess.reset(s0) + self._t = 0.0 return _obs_from_output(self._sess.output()) def step(self, u): @@ -307,12 +431,122 @@ def step(self, u): delta, fx = float(u[0]), float(u[1]) if not math.isfinite(delta) or not math.isfinite(fx): raise ValueError("delta and Fx_total must be finite") + if self._trace is not None: + # Ask before building the sample: on a decimated step the dict + # construction is the whole cost, so skipping it is the saving. + if self._trace.due: + self._record(delta, fx) + else: + self._trace.skip() cmd = _fx_to_cmdl1(self._vp, delta, fx) for _ in range(self._n_sub): self._sess.set_input(cmd) self._sess.tick(self.substep_dt) + self._t += self.control_dt return self._obs() + # ---- trace recording (opt-in) --------------------------------------- + def enable_trace(self, path, decimation=None, seed=None, run_id=None, + producer=None, tags=None, role="plant"): + """Start recording this run to a ``.vdtrace``. Off unless called. + + One sample is offered per :meth:`step`, taken *before* the step is + integrated: the pose/forces are the state at time ``t`` and ``u_steer`` / + ``u_fx`` are the command held over ``[t, t+control_dt)``. + + :param path: output ``.vdtrace`` path. + :param decimation: keep 1 of every N samples. ``None`` picks the smallest + N whose record rate stays at or above ``TRACE_TARGET_HZ`` (100 Hz), + which is ample for a 30 fps render — 1 kHz control gives N=10, while + a 20 Hz control loop stays at N=1 and loses nothing. + :param seed: RNG seed of the run, recorded for replay. + :param run_id: run identifier; defaults to the trace file stem. + :param producer: ``{"name","version"}`` of the calling script. + :param tags: free-form dict merged into the manifest as ``tags``. + :param role: manifest ``role`` (§3.1). Defaults to ``"plant"`` because + this class *is* the plant; pass ``"predictor"`` when the same object + is driven as an optimiser's internal model rather than as the run + under verification. + :returns: the resolved decimation. + """ + import vdsim_trace + + if self._trace is not None: + raise RuntimeError("trace already enabled; call finalize_trace() first") + if decimation is None: + decimation = max(1, int(round(1.0 / (TRACE_TARGET_HZ * self.control_dt)))) + decimation = int(decimation) + + shape, aniso = measure_mu_aniso(self._ts.wheel[0]) + path = Path(path) + geometry = { + "wheelbase_m": float(self._vp.wheelbase), + "track_m": float(self._vp.track_front), + "steer_ratio": float(self._vp.steering_ratio), + "track_front_m": float(self._vp.track_front), + "track_rear_m": float(self._vp.track_rear), + "cg_to_front_m": float(self._vp.cg_to_front), + "cg_to_rear_m": float(self._vp.cg_to_rear), + "wheel_radius_m": float(self._vp.wheel_radius_nominal), + } + repro = { + "vdsim_version": _vdsim_version(), + "git_sha": _git_sha(), + "param_hash": vdsim_trace.param_hash(self._plant_params), + "seed": seed, + "dt_s": float(self.control_dt * decimation), + "run_id": run_id or path.stem, + "control_dt_s": float(self.control_dt), + "substep_dt_s": float(self.substep_dt), + "decimation": decimation, + } + self._trace = vdsim_trace.TraceWriter( + path=path, + geometry=geometry, + tire={"friction_shape": shape, "mu_aniso": aniso, + "mu_aniso_source": "measured"}, + repro=repro, + producer=producer or {"name": Path(sys.argv[0]).name or "python", + "version": _vdsim_version()}, + decimation=decimation, + extra={"tags": dict(tags or {})}, + role=role, + ) + return decimation + + def finalize_trace(self): + """Flush channels, freeze the manifest and close the trace. + + :returns: the written path, or ``None`` when recording was never enabled. + """ + if self._trace is None: + return None + writer, self._trace = self._trace, None + return writer.finalize() + + @property + def trace_path(self): + """Path of the trace currently being recorded, or ``None``.""" + return None if self._trace is None else self._trace.path + + def _record(self, delta: float, fx: float): + o = self._sess.output() + st = o.state + self._trace.append({ + "t": self._t, + "pose": (float(st.position[0]), float(st.position[1]), float(st.yaw())), + "v_body": (float(st.vx()), float(st.vy())), + "yaw_rate": float(st.yaw_rate()), + "u_steer": delta, + "u_fx": fx, + "wheel_F": [(float(o.tire_forces_wheel[i][0]), + float(o.tire_forces_wheel[i][1]), + float(o.Fz[i])) for i in range(4)], + "wheel_mu": [float(o.wheel_mu[i]) for i in range(4)], + "wheel_kappa": [float(o.slip_ratio[i]) for i in range(4)], + "wheel_alpha": [float(o.slip_angle[i]) for i in range(4)], + }) + @property def vehicle(self): return _VehicleView(self._vp, _TireView(self._dyn, self._ts)) diff --git a/python/vdsim_preset.py b/python/vdsim_preset.py new file mode 100644 index 00000000..35d5b6ef --- /dev/null +++ b/python/vdsim_preset.py @@ -0,0 +1,258 @@ +"""vdsim_preset — render presets for the headless trace renderer. + +A preset is **renderer configuration, not trace schema** (contract +``11_trace_contract_spec.md`` §6.3). It only decides which view of one +``.vdtrace`` is drawn; it never changes what the trace contains, which is why +adding presets does not move ``schema_version``. + +Three presets are contracted — ``overview``, ``control``, ``tire_limit`` — and +the implementation order is fixed in that order. This module ships +``overview`` only; the other two names are deliberately absent rather than +stubbed, so ``--preset control`` fails loudly instead of silently rendering +something that is not the contracted screen. + +Resolution order, highest first (§6.3):: + + CLI option > user preset file > built-in preset default + +A preset declares panel kinds, channels, ranges and visibility — that is the +whole MVP surface. Free-form layout editing is out of scope: panel positions +and the BEV axis coordinate system are fixed by the renderer, and a preset +that pushed text or a panel outside the frame would be a failure, not a +layout choice. + +The module depends on the standard library only. YAML presets additionally +need PyYAML; JSON always works. +""" +from __future__ import annotations + +import copy +import json +from pathlib import Path + +#: Preset resolution order, documented for callers that report it. +PRECEDENCE = ("cli", "user-preset", "builtin") + +#: Preset names reserved by the contract but not implemented yet. ``road_contact`` +#: is re-adjudicated only after the core C2 contact coupling lands (§6.3): naming +#: a preset after contact data the core does not yet resolve would advertise C2 +#: while C1 is what actually runs. +RESERVED_PRESET_NAMES = ("road_contact", "control", "tire_limit") + +#: Panel channels the renderer derives rather than reads from the trace. +#: ``speed`` is the magnitude of ``v_body``; it is not a recorded channel +#: because it is a function of one. +DERIVED_PANELS = ("speed",) + +_BEV_DEFAULTS = { + "show": True, # the BEV axis itself + "waypoint": True, # path2d overlays (the global route) + "regions": True, # region overlays (e.g. a low-mu patch) + "trail": True, # the driven path so far + "velocity_arrow": True, + "utilization_color": True, # per-wheel friction colouring + "colorbar": True, + "legend": True, + "view_half_m": None, # None keeps the geometry-derived window +} + +_PANEL_DEFAULTS = { + "channel": None, + "label": None, # None uses the channel name + "ylim": None, # None autoscales; autoscale is allowed, escaping is not + "required": False, # a missing optional channel drops its panel silently +} + +#: Built-in presets. ``overview`` is the contracted "quick look at the driven +#: result": BEV + driven path + optional reference path + speed + steer/long +#: commands. +BUILTIN_PRESETS = { + "overview": { + "name": "overview", + "description": "quick look at a driven result — BEV, driven path, " + "optional reference path, speed, steer and long. command", + "bev": dict(_BEV_DEFAULTS), + "panels": [ + {"channel": "speed", "label": "speed"}, + {"channel": "u_steer", "label": "steer cmd"}, + {"channel": "u_fx", "label": "long. cmd"}, + ], + }, +} + +_TOP_KEYS = ("name", "description", "extends", "bev", "panels") + + +class PresetError(ValueError): + """A preset file or name violates the §6.3 configuration contract.""" + + +def builtin_names(): + """Names of the presets shipped with the renderer.""" + return tuple(sorted(BUILTIN_PRESETS)) + + +def _deep_merge(base: dict, over: dict) -> dict: + """Return ``base`` overlaid with ``over``; nested dicts merge key-wise.""" + out = copy.deepcopy(base) + for k, v in over.items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = copy.deepcopy(v) + return out + + +def _normalize_panels(panels) -> list: + if not isinstance(panels, (list, tuple)): + raise PresetError("preset 'panels' must be a list, got %r" % (type(panels).__name__,)) + out = [] + for i, p in enumerate(panels): + if isinstance(p, str): + p = {"channel": p} + if not isinstance(p, dict): + raise PresetError("panel %d must be a channel name or an object" % (i,)) + unknown = [k for k in p if k not in _PANEL_DEFAULTS] + if unknown: + raise PresetError( + "panel %d has unknown key(s) %s; allowed: %s" + % (i, ", ".join(sorted(unknown)), ", ".join(sorted(_PANEL_DEFAULTS)))) + entry = dict(_PANEL_DEFAULTS) + entry.update(p) + if not entry["channel"] or not isinstance(entry["channel"], str): + raise PresetError("panel %d needs a non-empty string 'channel'" % (i,)) + ylim = entry["ylim"] + if ylim is not None: + if (not isinstance(ylim, (list, tuple)) or len(ylim) != 2 + or not all(isinstance(v, (int, float)) for v in ylim) + or float(ylim[0]) >= float(ylim[1])): + raise PresetError( + "panel %r 'ylim' must be [lo, hi] with lo < hi, got %r" + % (entry["channel"], ylim)) + entry["ylim"] = (float(ylim[0]), float(ylim[1])) + out.append(entry) + return out + + +def normalize_preset(raw, source: str = "") -> dict: + """Validate one preset object and fill in the built-in defaults. + + A user preset is layered on the built-in named by ``extends`` (default + ``overview``), so a file that only changes one panel range does not have + to restate the whole screen. + + :param raw: parsed preset object. + :param source: name used in error messages. + :returns: a complete preset dict. + :raises PresetError: on an unknown key, a reserved name or a malformed panel. + """ + if not isinstance(raw, dict): + raise PresetError("%s: a preset must be a JSON/YAML object" % (source,)) + unknown = [k for k in raw if k not in _TOP_KEYS] + if unknown: + raise PresetError( + "%s: unknown preset key(s) %s; allowed: %s" + % (source, ", ".join(sorted(unknown)), ", ".join(_TOP_KEYS))) + + name = raw.get("name") or Path(source).stem + if name in RESERVED_PRESET_NAMES: + raise PresetError( + "%s: preset name %r is reserved by the render contract and not " + "implemented yet — pick another name rather than occupying it" + % (source, name)) + + base_name = raw.get("extends", "overview") + if base_name not in BUILTIN_PRESETS: + raise PresetError( + "%s: 'extends' must name a built-in preset %s, got %r" + % (source, list(builtin_names()), base_name)) + + merged = _deep_merge(BUILTIN_PRESETS[base_name], raw) + merged.pop("extends", None) + merged["name"] = name + + bev = merged.get("bev", {}) + if not isinstance(bev, dict): + raise PresetError("%s: 'bev' must be an object" % (source,)) + unknown = [k for k in bev if k not in _BEV_DEFAULTS] + if unknown: + raise PresetError( + "%s: unknown bev key(s) %s; allowed: %s" + % (source, ", ".join(sorted(unknown)), ", ".join(sorted(_BEV_DEFAULTS)))) + view_half = bev.get("view_half_m") + if view_half is not None and not (isinstance(view_half, (int, float)) and view_half > 0): + raise PresetError("%s: bev.view_half_m must be a positive number or null, got %r" + % (source, view_half)) + merged["bev"] = _deep_merge(_BEV_DEFAULTS, bev) + merged["panels"] = _normalize_panels(merged.get("panels", [])) + return merged + + +def load_preset(spec=None) -> dict: + """Resolve ``spec`` to a complete preset. + + :param spec: built-in preset name, path to a ``.yaml``/``.yml``/``.json`` + preset file, an already-parsed dict, or ``None`` for ``overview``. + :returns: a complete preset dict. + :raises PresetError: on an unknown name, an unreadable file or an invalid + preset body. + """ + if spec is None: + spec = "overview" + if isinstance(spec, dict): + return normalize_preset(spec, "") + text = str(spec) + if text in BUILTIN_PRESETS: + return normalize_preset(copy.deepcopy(BUILTIN_PRESETS[text]), text) + + p = Path(text) + if not p.is_file(): + raise PresetError( + "unknown preset %r — built-ins are %s; anything else must be a path " + "to a .yaml/.yml/.json preset file" + % (text, list(builtin_names()))) + body = p.read_text(encoding="utf-8") + if p.suffix.lower() in (".yaml", ".yml"): + try: + import yaml + except ImportError: + raise PresetError( + "%s is YAML but PyYAML is not installed; install it or write " + "the preset as JSON" % (p,)) + raw = yaml.safe_load(body) + elif p.suffix.lower() == ".json": + try: + raw = json.loads(body) + except ValueError as exc: + raise PresetError("%s is not valid JSON: %s" % (p, exc)) + else: + raise PresetError( + "%s: a preset file must be .yaml, .yml or .json (got %r)" + % (p, p.suffix)) + return normalize_preset(raw, str(p)) + + +def apply_overrides(preset: dict, panels=None, view_half=None, + waypoint=None, legend=None) -> dict: + """Layer CLI options on top of a resolved preset — the top of §6.3's order. + + Every argument is ``None`` when the caller did not pass the option, so an + unset CLI flag never overwrites a preset value. + + :param preset: preset from :func:`load_preset`. + :param panels: replacement channel list (names only). + :param view_half: BEV half-window [m]. + :param waypoint: draw path2d overlays. + :param legend: draw the BEV legend. + :returns: a new preset dict; the input is not mutated. + """ + out = copy.deepcopy(preset) + if panels is not None: + out["panels"] = _normalize_panels(list(panels)) + if view_half is not None: + out["bev"]["view_half_m"] = float(view_half) + if waypoint is not None: + out["bev"]["waypoint"] = bool(waypoint) + if legend is not None: + out["bev"]["legend"] = bool(legend) + return out diff --git a/python/vdsim_render.py b/python/vdsim_render.py new file mode 100644 index 00000000..b20c78a0 --- /dev/null +++ b/python/vdsim_render.py @@ -0,0 +1,1698 @@ +"""vdsim_render — headless renderer for ``.vdtrace`` runs. + +Input is trace files and nothing else: no simulation is re-run and no +results file is re-parsed, so the animation is reproducible from the artifact +alone. Output is a GIF plus a preview PNG (MP4 only when ``imageio-ffmpeg`` +happens to be installed) using the existing ``[plot]`` extra — matplotlib and +pillow. There is no GUI window and no node/npm/browser dependency. + +Screen contents (contract §6.1): + +1. reference path, dashed — from a ``path2d`` overlay; omitted when absent +2. driven path +3. body rectangle, sized from manifest ``geometry`` +4. front wheels turned by the recorded steering command +5. velocity arrow +6. HUD in fixed screen coordinates +7. per-wheel friction-utilization colour +8. command time series (``u_steer`` / ``u_fx``) with a current-time cursor + +Three curves, three different things +------------------------------------ +They are routinely conflated under the word "reference"; they are not the +same object and the renderer keeps them apart by name. + +* **waypoint** — the global route the run has to follow. Time-invariant, + one per scenario, carried by a ``path2d`` overlay. Drawn once, dashed grey. +* **target** — the reference horizon handed to the controller at *this* + step (N+1 points). Time-varying: the point spacing follows the planned + speed, so it compresses under braking. Blue, with markers, so the + spacing is visible. +* **prediction** — the horizon the controller solved for at this step + (N+1 points). Time-varying; on a failed solve it is whatever the solver + returned, recorded as-is. Orange, switching to thick red while the + reported solver status is non-zero. + +``target`` and ``prediction`` come from a *sidecar* ``.qp.npz`` +written next to the trace: they are ``(K, N+1, 2)`` and the trace +container's channel table is a whitelist of scalar / fixed-width rows. The +sidecar is optional — without it the renderer behaves exactly as before. + +Structure: :func:`frame_spec` is a pure function from (loaded trace, frame +index) to a plain description of the frame, and :func:`draw_frame` renders that +description. The split is what makes the view window testable — axis limits +come from manifest ``geometry`` and never from matplotlib autoscale, which +would otherwise drift frame to frame and silently rescale the animation. + +Two or more traces switch the renderer into *overlay* mode (§ "multi-run +overlay" below): one camera, one clock, N vehicles drawn on top of each other +in distinct colours with matching driven paths. That mode exists to compare +runs — a controller against its baseline, the same scenario at three friction +levels — so the picture answers "where do they diverge, and when". + +CLI:: + + python -m vdsim_render run.vdtrace --out run.gif + python -m vdsim_render a.vdtrace b.vdtrace c.vdtrace --out compare.gif +""" +from __future__ import annotations + +import argparse +import math +import time +from pathlib import Path + +import numpy as np + +import matplotlib +matplotlib.use("Agg") # headless: no GUI backend, importable over ssh +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.animation import FuncAnimation, PillowWriter # noqa: E402 +from matplotlib.collections import PolyCollection # noqa: E402 +from matplotlib.colors import LinearSegmentedColormap, Normalize # noqa: E402 +from matplotlib.gridspec import GridSpec # noqa: E402 + +try: + from vdsim_trace import TraceReader, utilization + import vdsim_preset +except ImportError: # in-tree run + import sys + sys.path.insert(0, str(Path(__file__).resolve().parent)) + from vdsim_trace import TraceReader, utilization + import vdsim_preset + +#: BEV half-window, in wheelbases. The tracking camera's extent is a pure +#: function of manifest `geometry`, which is what keeps the scale constant. +VIEW_HALF_WHEELBASES = 6.0 +#: Body rectangle proportions, in wheelbases / tracks. Used only when the +#: manifest does not carry explicit body dimensions. +BODY_LENGTH_PER_WHEELBASE = 1.56 +BODY_WIDTH_PER_TRACK = 1.18 +#: Drawn tyre width [m] (a drawing proportion, not a vehicle parameter). +TYRE_WIDTH_M = 0.245 +#: Utilization at which a wheel is flagged as close to the friction limit. +UTIL_WARN = 0.8 +#: Colour scale ceiling; >1.0 keeps saturated wheels distinguishable. +UTIL_MAX = 1.2 +#: Velocity arrow length per (m/s), in wheelbases. +ARROW_S_PER_MPS = 0.06 +#: Channels the command panel prefers, in order. Anything absent is skipped and +#: the list is intersected with the manifest — never assumed present. +PREFERRED_SERIES = ("u_steer", "u_fx") +#: Minimum and rounding step for the speedometer's static full-scale [km/h]. +SPEED_GAUGE_MIN_KMH = 40.0 +SPEED_GAUGE_STEP_KMH = 20.0 + +UTIL_CMAP = LinearSegmentedColormap.from_list( + "vdsim_util", ["#2c9c4c", "#9acd32", "#f5c542", "#e8752a", "#c02020"]) +UTIL_NORM = Normalize(vmin=0.0, vmax=UTIL_MAX) + +#: Suffix of the optional horizons sidecar, resolved from the trace path. +SIDECAR_SUFFIX = ".qp.npz" +#: Overlay names that mean *the global route*. Traces recorded before the +#: waypoint / target / prediction split called it ``reference_path`` or +#: ``ref_path``; the aliases keep them rendering with the right label. +WAYPOINT_NAMES = ("waypoint", "reference_path", "ref_path") +WAYPOINT_LABEL = "waypoint" +#: Grey and dashed, so the two *horizon* curves own the saturated colours. +WAYPOINT_COLOR = "#8c8c8c" +#: MPC input horizon: markers on, so the point spacing (and its +#: compression under braking) is readable. +TARGET_COLOR = "#1e9de8" +TARGET_LABEL = "target (MPC input)" +#: MPC output horizon. +PRED_COLOR = "#e8752a" +PRED_LABEL = "prediction (MPC output)" +#: Prediction styling once the solver reports a non-zero status. +PRED_FAIL_COLOR = "#c02020" +PRED_LW = 1.8 +PRED_FAIL_LW = 3.2 +#: Margin added to an explicitly requested view half-width. +VIEW_MARGIN_FRAC = 0.05 + + +def load_sidecar(path, required: bool = False): + """Load the ``.qp.npz`` horizons sidecar that sits next to a trace. + + :param path: the sidecar itself, or the ``.vdtrace`` it belongs to. + :param required: raise when it is missing instead of returning ``None``. + :returns: dict of arrays, or ``None`` when there is no sidecar. + """ + p = Path(path) + if p.suffix != ".npz": + p = p.with_suffix(SIDECAR_SUFFIX) + if not p.is_file(): + if required: + raise FileNotFoundError("sidecar not found: %s" % (p,)) + return None + with np.load(str(p)) as z: + data = {k: np.asarray(z[k]) for k in z.files} + for key in ("t", "tgt_XY", "pred_XY", "status"): + if key not in data: + raise ValueError("sidecar %s is missing %r" % (p, key)) + return data + + +def nearest_time_index(t_query, t_ref): + """Index of the nearest ``t_ref`` sample for each ``t_query`` sample. + + The control period and the trace decimation are independent, so the + sidecar and the trace are aligned on time rather than on index. + """ + t_ref = np.asarray(t_ref, dtype=float) + t_query = np.asarray(t_query, dtype=float) + if t_ref.size <= 1: + return np.zeros(t_query.shape, dtype=int) + j = np.clip(np.searchsorted(t_ref, t_query), 1, t_ref.size - 1) + take_left = (t_query - t_ref[j - 1]) <= (t_ref[j] - t_query) + return np.where(take_left, j - 1, j).astype(int) + + +class LoadedTrace: + """Every array the renderer needs, loaded once from a ``.vdtrace``. + + Holding the decoded arrays separately from the reader keeps + :func:`frame_spec` free of file I/O, so it stays a pure function. + + :param path: ``.vdtrace`` path. + :param stride: keep every ``stride``-th recorded sample as one output frame. + :param sidecar: explicit ``.qp.npz`` with the per-step target and + prediction horizons. ``None`` looks for ``.qp.npz`` and + accepts its absence. + :param panels: preset panel list (see :mod:`vdsim_preset`) choosing the + time-series panels. ``None`` keeps the manifest-driven default. + """ + + def __init__(self, path, stride: int = 1, sidecar=None, panels=None): + stride = max(1, int(stride)) + with TraceReader(path) as tr: + self.manifest = tr.manifest + self.geometry = dict(tr.geometry) + self.tire = dict(tr.tire) + self.repro = dict(tr.repro) + self.role = tr.role + self.wheels = tr.wheels + self.available = tr.channel_names() + self.t = np.asarray(tr.channel("t")) + self.pose = np.asarray(tr.channel("pose")) + self.v_body = (np.asarray(tr.channel("v_body")) + if tr.has("v_body") else np.zeros((len(self.t), 2))) + self.yaw_rate = (np.asarray(tr.channel("yaw_rate")) + if tr.has("yaw_rate") else np.zeros(len(self.t))) + self.util = (tr.utilization() + if tr.has("wheel_F") and tr.has("wheel_mu") + else np.zeros((len(self.t), 4))) + # Command panel series are chosen from the manifest, not hardcoded. + names = [n for n in PREFERRED_SERIES if tr.has(n)] + if not names: + names = [c["name"] for c in self.manifest["channels"] + if len(c["shape"]) == 1 and c["name"] != "t"][:2] + self.series = [(n, tr.channel_meta(n).get("unit", ""), + np.asarray(tr.channel(n))) for n in names] + self.panels = [{"channel": n, "label": n, "ylim": None, + "required": False} for n in names] + # Raw scalar channels, kept under their trace names. Panel series + # carry preset *labels*, so geometry lookups such as ``u_steer`` + # must not go through them or a relabelled panel would silently + # straighten the front wheels. + self._raw_scalar = { + c["name"]: np.asarray(tr.channel(c["name"])) + for c in self.manifest["channels"] + if len(c["shape"]) == 1 and c["name"] != "t"} + if panels is not None: + self.series, self.panels = self._resolve_panels(tr, panels) + self.overlays = [tr.overlay(n) for n in tr.overlay_names()] + + self.stride = stride + self.frames = np.arange(0, len(self.t), stride, dtype=int) + self.steer = self._series_or_zeros("u_steer") + self.steer_ratio = float(self.geometry.get("steer_ratio", 1.0)) + self.speed_gauge_max_kmh = speed_gauge_max_kmh(self.v_body) + self.body_l, self.body_w = body_size(self.geometry) + self.view_half = view_half_m(self.geometry) + self.sidecar = load_sidecar(path if sidecar is None else sidecar, + required=sidecar is not None) + self.sc_map = (nearest_time_index(self.t, self.sidecar["t"]) + if self.sidecar is not None else None) + # How far the horizons reach. The default window is a function of + # manifest geometry only (that is what keeps the scale constant), so + # this is reported rather than silently applied. + self.horizon_reach = self._horizon_reach() + #: Window half-width actually used; ``render`` may override it. + self.view_half_used = self.view_half + + def _resolve_panels(self, tr, panels): + """Pick the panels a preset asks for from what this trace carries. + + A missing *optional* channel drops its panel and is not an error + (§6.3): the same preset has to work on a trace recorded with a channel + subset. A missing channel marked ``required`` is an error, because a + preset that declares it cannot show what it promises without it. + + :param tr: open :class:`~vdsim_trace.TraceReader`. + :param panels: normalized preset panel list. + :returns: ``(series, panels)`` for the panels that survived. + """ + speed = None + if tr.has("v_body"): + vb = np.asarray(tr.channel("v_body"), dtype=float) + speed = np.hypot(vb[:, 0], vb[:, 1]) + series, kept = [], [] + for spec in panels: + name = spec["channel"] + if name == "speed": + if speed is None: + arr, unit = None, "" + else: + arr, unit = speed, "m/s" + elif tr.has(name) and len(tr.channel_meta(name)["shape"]) == 1: + arr = np.asarray(tr.channel(name)) + unit = tr.channel_meta(name).get("unit", "") + else: + arr, unit = None, "" + if arr is None: + if spec.get("required"): + raise ValueError( + "preset panel %r is required but this trace does not " + "carry it (channels: %s)" + % (name, ", ".join(tr.channel_names()))) + continue + series.append((spec.get("label") or name, unit, arr)) + kept.append(spec) + return series, kept + + def _series_or_zeros(self, name): + """Raw channel ``name``, or zeros when the trace does not carry it.""" + arr = self._raw_scalar.get(name) + if arr is not None: + return arr + return np.zeros(len(self.t)) + + @property + def n_frames(self) -> int: + """Number of animation frames after striding.""" + return len(self.frames) + + def path2d_overlays(self): + """Reference paths to draw dashed (overlay ``kind == "path2d"``).""" + return [o for o in self.overlays + if isinstance(o, dict) and o.get("kind") == "path2d"] + + def region_overlays(self): + """Filled regions to draw under the path (overlay ``kind == "region"``).""" + return [o for o in self.overlays + if isinstance(o, dict) and o.get("kind") == "region"] + + def event_overlays(self): + """Time markers for the command panel (overlay ``kind == "event"``).""" + return [o for o in self.overlays + if isinstance(o, dict) and o.get("kind") == "event"] + + def _horizon_reach(self) -> float: + """Farthest horizon point from the vehicle over the run [m]. + + 0.0 without a sidecar. Failed steps can hold NaN or absurd values, + so only finite points count. + """ + if self.sidecar is None: + return 0.0 + if "ego_XY" in self.sidecar: + ego = np.asarray(self.sidecar["ego_XY"], dtype=float) + else: + # One pose per *sidecar step*. sc_map runs the other way (one + # entry per trace sample) and would be the wrong length here. + ego = self.pose[nearest_time_index(self.sidecar["t"], self.t), 0:2] + reach = 0.0 + for key in ("tgt_XY", "pred_XY"): + xy = np.asarray(self.sidecar[key], dtype=float) + d = np.linalg.norm(xy - ego[:, None, :], axis=2) + d = d[np.isfinite(d)] + if d.size: + reach = max(reach, float(d.max())) + return reach + + def has_sidecar(self) -> bool: + """Whether per-step target / prediction horizons were loaded.""" + return self.sidecar is not None + + def qp_fail_times(self): + """Times [s] whose solve reported a non-zero solver status.""" + if self.sidecar is None: + return np.zeros(0) + t = np.asarray(self.sidecar["t"], dtype=float) + return t[np.asarray(self.sidecar["status"], dtype=float) != 0.0] + + def first_fail_frame(self): + """Recorded-sample index of the first failed solve; ``None`` if none.""" + if self.sidecar is None: + return None + bad = np.asarray(self.sidecar["status"], dtype=float)[self.sc_map] != 0.0 + return int(np.argmax(bad)) if bad.any() else None + + +# -------------------------------------------------------------------------- +# pure geometry / frame description +# -------------------------------------------------------------------------- + +def view_half_m(geometry: dict) -> float: + """Half-width of the BEV tracking window [m], derived from ``geometry``. + + Deriving it from the wheelbase — not from the data extent — is what makes + the animation scale constant across frames and across runs of the same + vehicle. + """ + return VIEW_HALF_WHEELBASES * float(geometry["wheelbase_m"]) + + +def body_size(geometry: dict): + """Body rectangle ``(length, width)`` [m]. + + Explicit ``body_length_m`` / ``body_width_m`` win; otherwise the rectangle + is proportioned from the wheelbase and track already in the manifest. + """ + wb = float(geometry["wheelbase_m"]) + tr = float(geometry["track_m"]) + length = float(geometry.get("body_length_m", BODY_LENGTH_PER_WHEELBASE * wb)) + width = float(geometry.get("body_width_m", BODY_WIDTH_PER_TRACK * tr)) + return length, width + + +def _rect(cx, cy, length, width, angle): + """Corner list of a rectangle centred at (cx, cy), rotated by ``angle`` [rad].""" + c, s = math.cos(angle), math.sin(angle) + hl, hw = length * 0.5, width * 0.5 + pts = ((hl, hw), (hl, -hw), (-hl, -hw), (-hl, hw)) + return [(cx + c * x - s * y, cy + s * x + c * y) for x, y in pts] + + +def body_and_wheels(geometry: dict, body_l: float, body_w: float, + x: float, y: float, yaw: float, delta: float): + """Body rectangle and the four tyre rectangles for one pose. + + Shared by the single-run and the overlay renderer so both draw a vehicle + the same way — the overlay would otherwise grow a second, silently + diverging copy of the wheel placement. + + :param geometry: manifest ``geometry`` block of the run being drawn. + :param body_l: body rectangle length [m] (see :func:`body_size`). + :param body_w: body rectangle width [m]. + :param x: CG position X [m], world frame. + :param y: CG position Y [m], world frame. + :param yaw: heading [rad]. + :param delta: road-wheel steer angle [rad], applied to the front pair. + :returns: ``(body_polygon, [FL, FR, RL, RR])`` corner lists in world frame. + """ + wb = float(geometry["wheelbase_m"]) + track_f = float(geometry.get("track_front_m", geometry["track_m"])) + track_r = float(geometry.get("track_rear_m", geometry["track_m"])) + lf = float(geometry.get("cg_to_front_m", 0.5 * wb)) + lr = float(geometry.get("cg_to_rear_m", wb - 0.5 * wb)) + radius = float(geometry.get("wheel_radius_m", 0.34)) + c, s = math.cos(yaw), math.sin(yaw) + + def to_world(bx, by): + return (x + c * bx - s * by, y + s * bx + c * by) + + # Wheel centres in body frame, FL / FR / RL / RR (ISO 8855: +y left). + corners = ((lf, 0.5 * track_f, delta), (lf, -0.5 * track_f, delta), + (-lr, 0.5 * track_r, 0.0), (-lr, -0.5 * track_r, 0.0)) + wheels = [] + for bx, by, steer in corners: + wx, wy = to_world(bx, by) + wheels.append(_rect(wx, wy, 2.0 * radius, TYRE_WIDTH_M, yaw + steer)) + body = _rect(*to_world(0.5 * (lf - lr), 0.0), body_l, body_w, yaw) + return body, wheels + + +def velocity_arrow(geometry: dict, x: float, y: float, yaw: float, + vx: float, vy: float): + """Velocity arrow ``(x, y, dx, dy)`` [m] for a body-frame velocity.""" + wb = float(geometry["wheelbase_m"]) + c, s = math.cos(yaw), math.sin(yaw) + speed = math.hypot(vx, vy) + ax_, ay_ = (c * vx - s * vy, s * vx + c * vy) + norm = max(math.hypot(ax_, ay_), 1e-9) + arrow_len = ARROW_S_PER_MPS * wb * speed + return (x, y, ax_ / norm * arrow_len, ay_ / norm * arrow_len) + + +def _fmt_util(u: float) -> str: + """Format one utilization value for the HUD. + + A wheel with mu*Fz = 0 (the sample recorded at reset, before the first + integration step) divides by zero, and printing the raw 1e94 that comes + back overran the HUD box. Out-of-range values are named, not shown. + """ + if not np.isfinite(u): + return " n/a" + if abs(u) >= 100.0: + return ">>99" + return "%4.2f" % u + + +def series_for_display(unit: str, values): + """Return ``(display_unit, values)`` for a plotted trace channel. + + Trace channels keep their native SI representation. Matplotlib-facing + angular channels are converted here so every visible angle uses degrees, + while vehicle geometry and interpolation continue to operate in radians. + + :param unit: channel unit from the trace manifest. + :param values: channel samples in their recorded unit. + :returns: display unit and a NumPy array of display values. + """ + raw_unit = str(unit or "") + normalized = raw_unit.strip().lower() + for prefix in ("radians", "radian", "rad"): + if not normalized.startswith(prefix): + continue + suffix = normalized[len(prefix):] + # Accept an angle itself and compound angular units such as rad/s, + # rad s^-1 and rad/s^2. Reject unrelated words beginning with rad. + if suffix and suffix[0] not in "/ *": + continue + display_unit = "deg" + suffix + return display_unit, np.degrees(np.asarray(values, dtype=float)) + return raw_unit, np.asarray(values) + + +def speed_gauge_max_kmh(v_body) -> float: + """Stable speedometer full-scale [km/h] for a whole trace. + + The scale is chosen once at load time, then kept fixed across frames so the + needle never changes meaning during an animation. + """ + v = np.asarray(v_body, dtype=float) + speed = np.hypot(v[:, 0], v[:, 1]) * 3.6 if v.size else np.zeros(0) + finite = speed[np.isfinite(speed)] + peak = float(finite.max()) if finite.size else 0.0 + return max(SPEED_GAUGE_MIN_KMH, + SPEED_GAUGE_STEP_KMH * math.ceil(peak / SPEED_GAUGE_STEP_KMH)) + + +def instrument_spec(road_steer_rad: float, steer_ratio: float, + speed_mps: float, speed_max_kmh: float) -> dict: + """Pure T6 steering-wheel and speedometer description. + + :param road_steer_rad: recorded road-wheel angle [rad]. + :param steer_ratio: steering-wheel / road-wheel ratio from manifest geometry. + :param speed_mps: vehicle speed magnitude [m/s]. + :param speed_max_kmh: fixed speedometer full-scale [km/h]. + :returns: display values plus needle angles [rad]. + """ + wheel_rad = float(road_steer_rad) * float(steer_ratio) + speed_kmh = max(0.0, float(speed_mps) * 3.6) + scale = max(float(speed_max_kmh), 1e-9) + fraction = min(max(speed_kmh / scale, 0.0), 1.0) + return { + "steering_wheel_deg": math.degrees(wheel_rad), + "steering_needle_rad": wheel_rad, + "steer_ratio": float(steer_ratio), + "speed_kmh": speed_kmh, + "speed_max_kmh": scale, + # 225 deg (zero) clockwise over 270 deg to -45 deg (full scale). + "speed_needle_rad": math.radians(225.0 - 270.0 * fraction), + } + + +def frame_spec(tr: LoadedTrace, i: int) -> dict: + """Describe frame ``i`` — pure function, no matplotlib, no file access. + + :param tr: loaded trace. + :param i: index into the *recorded* samples (not the strided frame list). + :returns: dict with ``xlim``/``ylim`` (from manifest geometry), ``body`` + and ``wheels`` polygons, ``wheel_util`` colours' source values, + ``arrow``, ``trail``, ``hud`` lines and the time cursor. + """ + x, y, yaw = (float(v) for v in tr.pose[i]) + half = tr.view_half_used + delta = float(tr.steer[i]) + body, wheels = body_and_wheels(tr.geometry, tr.body_l, tr.body_w, + x, y, yaw, delta) + + vx, vy = (float(v) for v in tr.v_body[i]) + speed = math.hypot(vx, vy) + util = [float(u) for u in tr.util[i]] + + hud = [ + "t = %6.2f s" % (float(tr.t[i]),), + "v = %5.2f m/s (%5.1f km/h)" % (speed, speed * 3.6), + "beta = %+5.1f deg" % (math.degrees(math.atan2(vy, max(abs(vx), 1e-6))),), + "r = %+6.2f deg/s" % (math.degrees(float(tr.yaw_rate[i])),), + "delta = %+5.2f deg" % (math.degrees(delta),), + "util max = %s [%s]" % ( + _fmt_util(max(util) if util else 0.0), + " ".join("%s %s" % (w, _fmt_util(u)) + for w, u in zip(tr.wheels, util))), + # Displayed verbatim. The renderer never branches on the role — doing + # so would put a plant/predictor policy inside a drawing tool. + "role = %s" % (tr.role,), + ] + + # Horizons, when a sidecar was found. Without one the two artists are + # never created and these stay None, which is what keeps old traces + # rendering unchanged. + tgt_xy = pred_xy = None + status = None + solve_ms = float("nan") + if tr.sidecar is not None: + j = int(tr.sc_map[i]) + tgt_xy = np.asarray(tr.sidecar["tgt_XY"][j], dtype=float) + pred_xy = np.asarray(tr.sidecar["pred_XY"][j], dtype=float) + status = int(tr.sidecar["status"][j]) + if "solve_ms" in tr.sidecar: + solve_ms = float(tr.sidecar["solve_ms"][j]) + hud.append("solver = %-4s status %d%s" + % ("OK" if status == 0 else "FAIL", status, + "" if not np.isfinite(solve_ms) + else " %6.1f ms" % solve_ms)) + + return { + "index": int(i), + "t": float(tr.t[i]), + "xlim": (x - half, x + half), + "ylim": (y - half, y + half), + "body": body, + "wheels": wheels, + "wheel_util": util, + "arrow": velocity_arrow(tr.geometry, x, y, yaw, vx, vy), + "trail_x": tr.pose[: i + 1, 0], + "trail_y": tr.pose[: i + 1, 1], + "tgt_xy": tgt_xy, + "pred_xy": pred_xy, + "status": status, + "solve_ms": solve_ms, + "hud": hud, + "instruments": instrument_spec(delta, tr.steer_ratio, speed, + tr.speed_gauge_max_kmh), + } + + +# -------------------------------------------------------------------------- +# drawing +# -------------------------------------------------------------------------- + +def merge_spans(times, dt: float): + """Group consecutive failure times into ``(t_start, t_end)`` spans. + + A gap wider than 1.5 sampling periods starts a new span, so a run that + fails everywhere becomes one shaded band and a run that fails on three + scattered steps stays three marks. + """ + t = np.asarray(times, dtype=float) + if t.size == 0: + return [] + t = np.sort(t) + width = max(dt, 1e-9) + cut = np.nonzero(np.diff(t) > 1.5 * width)[0] + starts = np.concatenate(([0], cut + 1)) + ends = np.concatenate((cut, [t.size - 1])) + return [(float(t[a] - width / 2), float(t[b] + width / 2)) + for a, b in zip(starts, ends)] + + +def single_layout_spec(n_series: int) -> dict: + """Return non-overlapping normalized figure rectangles for one run. + + The T6 instruments occupy a dedicated strip below the BEV instead of + inset axes inside it. Keeping this geometry in a pure function makes the + fixed-screen contract testable without relying on artist draw order. + + :param n_series: number of command/time-series panels on the right. + :returns: ``bev``, ``steering``, ``speed`` rectangles and a ``series`` + tuple, each in Matplotlib ``[left, bottom, width, height]`` form. + """ + if n_series < 1: + raise ValueError("single-run layout requires at least one series panel") + series_bottom = 0.10 + series_top = 0.90 + series_gap = 0.025 + series_height = ((series_top - series_bottom) + - series_gap * (n_series - 1)) / n_series + series = tuple( + (0.685, + series_top - (k + 1) * series_height - k * series_gap, + 0.280, series_height) + for k in range(n_series) + ) + return { + "bev": (0.07, 0.300, 0.525, 0.600), + "steering": (0.145, 0.020, 0.145, 0.140), + "speed": (0.365, 0.020, 0.145, 0.140), + "series": series, + } + + +def _setup_axes(tr: LoadedTrace, figsize, dpi): + n_series = len(tr.series) + layout = single_layout_spec(max(n_series, 1)) + fig = plt.figure(figsize=figsize, dpi=dpi) + if n_series == 0: + # Every optional panel was dropped for lack of a channel. That is an + # allowed preset outcome, so the BEV takes the width the series column + # would have used rather than the renderer failing on an empty layout. + left, bottom, _, height = layout["bev"] + right = layout["series"][0][0] + layout["series"][0][2] + ax_bev = fig.add_axes((left, bottom, right - left, height)) + ax_series = [] + else: + ax_bev = fig.add_axes(layout["bev"]) + ax_series = [fig.add_axes(rect) for rect in layout["series"]] + ax_instruments = { + "steering": fig.add_axes(layout["steering"]), + "speed": fig.add_axes(layout["speed"]), + } + fig._vdsim_instrument_axes = ax_instruments + return fig, ax_bev, ax_series + + +def _instrument_artists(ax_instruments, tr: LoadedTrace): + """Create T6 artists in dedicated, geometrically isolated axes.""" + steer_ax = ax_instruments["steering"] + speed_ax = ax_instruments["speed"] + for ax, title in ((steer_ax, "steering wheel"), (speed_ax, "speed")): + ax.set_xlim(-1.15, 1.15) + ax.set_ylim(-1.28, 1.12) + ax.set_aspect("equal", adjustable="box") + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title(title, fontsize=6, pad=1.0) + ax.patch.set_facecolor("white") + ax.patch.set_alpha(0.82) + for spine in ax.spines.values(): + spine.set_edgecolor("#8a97a5") + spine.set_linewidth(0.7) + + ring_a = np.linspace(0.0, 2.0 * math.pi, 120) + steer_ax.plot(0.86 * np.cos(ring_a), 0.86 * np.sin(ring_a), + color="#273746", lw=1.5) + (steer_spokes,) = steer_ax.plot([], [], color="#273746", lw=1.4) + steer_text = steer_ax.text(0.0, -1.08, "", ha="center", va="center", + fontsize=5.8, family="monospace") + + arc_a = np.linspace(math.radians(225.0), math.radians(-45.0), 120) + speed_ax.plot(np.cos(arc_a), np.sin(arc_a), color="#273746", lw=1.5) + (speed_needle,) = speed_ax.plot([], [], color="#c02020", lw=1.6) + speed_ax.text(-0.78, -0.82, "0", ha="center", va="center", fontsize=5.5) + speed_ax.text(0.78, -0.82, "%g" % tr.speed_gauge_max_kmh, + ha="center", va="center", fontsize=5.5) + speed_text = speed_ax.text(0.0, -1.08, "", ha="center", va="center", + fontsize=5.8, family="monospace") + return dict(steer_spokes=steer_spokes, steer_text=steer_text, + speed_needle=speed_needle, speed_text=speed_text) + + +def _draw_static(tr: LoadedTrace, ax_bev, ax_series, ax_instruments=None, + bev=None): + """Draw everything that does not change per frame, and build the artists. + + :param ax_instruments: T6 gauge axes; taken from the figure when omitted. + :param bev: preset ``bev`` block toggling BEV layers. ``None`` draws every + layer, which is the behaviour of every caller that predates presets. + """ + if ax_instruments is None: + ax_instruments = ax_bev.figure._vdsim_instrument_axes + bev = dict(vdsim_preset.BUILTIN_PRESETS["overview"]["bev"]) if bev is None else bev + ax_bev.set_aspect("equal", adjustable="box") + ax_bev.set_xlabel("X [m]") + ax_bev.set_ylabel("Y [m]") + ax_bev.grid(True, alpha=0.25, lw=0.5) + + for reg in (tr.region_overlays() if bev.get("regions", True) else []): + poly = reg.get("polygon") or [] + if len(poly) >= 3: + ax_bev.add_collection(PolyCollection( + [poly], facecolors="#c44e52", alpha=0.16, + edgecolors="#c44e52", linewidths=0.8, zorder=0)) + for ref in (tr.path2d_overlays() if bev.get("waypoint", True) else []): + xy = np.asarray(ref.get("xy") or [], dtype=float) + if xy.size: + # The global route, drawn once. It is *not* the per-step target + # horizon, so it gets the muted style and the name to match. + is_wp = (str(ref.get("name", "")).lower() in WAYPOINT_NAMES + or str(ref.get("alias", "")).lower() in WAYPOINT_NAMES) + ax_bev.plot(xy[:, 0], xy[:, 1], ls="--", lw=1.2, + color=WAYPOINT_COLOR if is_wp else "#4c72b0", + alpha=0.9, zorder=1, + label=WAYPOINT_LABEL if is_wp + else ref.get("name", "reference")) + + (trail,) = ax_bev.plot([], [], "-", lw=1.4, color="#333333", alpha=0.85, zorder=2) + trail.set_visible(bool(bev.get("trail", True))) + body = PolyCollection([], facecolors="#dfe6ee", edgecolors="#1b2733", + linewidths=1.2, zorder=3) + ax_bev.add_collection(body) + wheels = PolyCollection([], edgecolors="#1b2733", linewidths=0.8, zorder=4) + wheels.set_cmap(UTIL_CMAP) + wheels.set_norm(UTIL_NORM) + ax_bev.add_collection(wheels) + arrow = ax_bev.annotate( + "", xy=(0, 0), xytext=(0, 0), zorder=5, + arrowprops=dict(arrowstyle="-|>", color="#d62728", lw=1.8, + shrinkA=0, shrinkB=0)) + arrow.set_visible(bool(bev.get("velocity_arrow", True))) + hud = ax_bev.text( + 0.015, 0.985, "", transform=ax_bev.transAxes, va="top", ha="left", + fontsize=8, family="monospace", zorder=10, + bbox=dict(boxstyle="round,pad=0.35", fc="white", ec="#8a97a5", alpha=0.85)) + + # Time-varying horizons. These are per-frame Line2D updates, not static + # draws: the target moves because the reference grid is re-sampled every + # step, the prediction because the OCP is re-solved every step. + target = prediction = qpfail = None + if tr.has_sidecar(): + (target,) = ax_bev.plot([], [], ls="-", lw=1.0, color=TARGET_COLOR, + marker="o", ms=2.6, mew=0.0, alpha=0.95, + zorder=6, label=TARGET_LABEL) + (prediction,) = ax_bev.plot([], [], ls="-", lw=PRED_LW, + color=PRED_COLOR, alpha=0.95, zorder=7, + label=PRED_LABEL) + qpfail = ax_bev.text( + 0.985, 0.015, "", transform=ax_bev.transAxes, va="bottom", + ha="right", fontsize=11, fontweight="bold", color="white", + zorder=11, + bbox=dict(boxstyle="round,pad=0.35", fc=PRED_FAIL_COLOR, + ec="none", alpha=0.92)) + qpfail.set_visible(False) + + if bev.get("legend", True) and ax_bev.get_legend_handles_labels()[0]: + # Lower left: the HUD owns the top-left corner in screen coordinates. + ax_bev.legend(loc="lower left", fontsize=7, framealpha=0.6) + + if bev.get("colorbar", True): + cbar = ax_bev.figure.colorbar( + plt.cm.ScalarMappable(norm=UTIL_NORM, cmap=UTIL_CMAP), + ax=ax_bev, fraction=0.035, pad=0.015) + cbar.set_label("tyre friction utilization [-]", fontsize=8) + cbar.ax.axhline(UTIL_WARN, color="#111111", lw=1.0, ls="--") + cbar.ax.tick_params(labelsize=7) + + cursors = [] + events = tr.event_overlays() + # QP-failure marks: the recorded ``qp_fail`` event overlay when there is + # one, otherwise derived from the sidecar status so a trace recorded + # without the overlay still shows them. + qp_events = [ev for ev in events if str(ev.get("name", "")).lower() == "qp_fail"] + fail_t = (np.unique(np.concatenate( + [np.asarray(ev.get("t", []), dtype=float) for ev in qp_events])) + if qp_events else tr.qp_fail_times()) + fail_spans = merge_spans(fail_t, float(np.median(np.diff(tr.t))) + if len(tr.t) > 1 else 0.0) + panel_specs = getattr(tr, "panels", [{}] * len(tr.series)) + for ax, (name, unit, arr), spec in zip(ax_series, tr.series, panel_specs): + display_unit, display_arr = series_for_display(unit, arr) + ax.plot(tr.t, display_arr, lw=1.1, color="#4c72b0") + ax.set_ylabel("%s [%s]" % (name, display_unit or "-"), fontsize=8) + ax.tick_params(labelsize=7) + ax.grid(True, alpha=0.25, lw=0.5) + ax.set_xlim(float(tr.t[0]), float(tr.t[-1])) + # Autoscale is allowed; a declared range wins over it (§6.3). + if spec.get("ylim") is not None: + ax.set_ylim(*spec["ylim"]) + for ev in events: + if ev in qp_events: + continue + for et in ev.get("t", []): + ax.axvline(float(et), color="#c02020", lw=0.8, alpha=0.6) + for t0, t1 in fail_spans: + # Shaded spans, not one line per failed step: a run that fails + # on every step drew 500 lines and filled the panel solid red, + # which reads as a rendering artifact rather than as a result. + ax.axvspan(t0, t1, color=PRED_FAIL_COLOR, alpha=0.18, lw=0, + zorder=0) + cursors.append(ax.axvline(float(tr.t[0]), color="#111111", lw=1.0)) + if ax_series: + ax_series[-1].set_xlabel("time [s]", fontsize=8) + instruments = _instrument_artists(ax_instruments, tr) + return dict(trail=trail, body=body, wheels=wheels, arrow=arrow, + hud=hud, cursors=cursors, target=target, + prediction=prediction, qpfail=qpfail, + n_qp_fail=int(len(fail_t)), + util_color=bool(bev.get("utilization_color", True)), + **instruments) + + +def draw_frame(artists, ax_bev, spec: dict): + """Apply a :func:`frame_spec` description to the prepared artists.""" + ax_bev.set_xlim(*spec["xlim"]) + ax_bev.set_ylim(*spec["ylim"]) + artists["trail"].set_data(spec["trail_x"], spec["trail_y"]) + artists["body"].set_verts([spec["body"]]) + artists["wheels"].set_verts(spec["wheels"]) + if artists.get("util_color", True): + artists["wheels"].set_array(np.asarray(spec["wheel_util"])) + else: + artists["wheels"].set_array(None) + artists["wheels"].set_facecolor("#dfe6ee") + x, y, dx, dy = spec["arrow"] + artists["arrow"].set_position((x, y)) + artists["arrow"].xy = (x + dx, y + dy) + artists["hud"].set_text("\n".join(spec["hud"])) + _draw_instruments(artists, spec["instruments"]) + _draw_horizons(artists, spec) + for cur in artists["cursors"]: + cur.set_xdata([spec["t"], spec["t"]]) + + +def _draw_instruments(artists, spec: dict): + """Update T6 instrument needles and numeric readouts.""" + a = float(spec["steering_needle_rad"]) + xs, ys = [], [] + for offset in (0.0, 2.0 * math.pi / 3.0, 4.0 * math.pi / 3.0): + xs.extend((0.0, 0.72 * math.cos(a + offset), np.nan)) + ys.extend((0.0, 0.72 * math.sin(a + offset), np.nan)) + artists["steer_spokes"].set_data(xs, ys) + artists["steer_text"].set_text("%+6.1f deg\nratio %.1f:1" % + (spec["steering_wheel_deg"], spec["steer_ratio"])) + + a = float(spec["speed_needle_rad"]) + artists["speed_needle"].set_data((0.0, 0.82 * math.cos(a)), + (0.0, 0.82 * math.sin(a))) + artists["speed_text"].set_text("%5.1f km/h" % spec["speed_kmh"]) + + +def _draw_horizons(artists, spec: dict): + """Update the target / prediction lines and the QP-failure banner. + + No-op for a trace without a sidecar: the artists were never created. + A failed step is drawn exactly as recorded — NaNs break the line into + gaps rather than being interpolated over, which is the honest picture + of a solver that returned nothing usable. + """ + target, pred = artists.get("target"), artists.get("prediction") + if target is None or pred is None: + return + tgt = spec.get("tgt_xy") + if tgt is not None and len(tgt): + tgt = np.asarray(tgt, dtype=float) + target.set_data(tgt[:, 0], tgt[:, 1]) + else: + target.set_data([], []) + pxy = spec.get("pred_xy") + if pxy is not None and len(pxy): + pxy = np.asarray(pxy, dtype=float) + pred.set_data(pxy[:, 0], pxy[:, 1]) + else: + pred.set_data([], []) + failed = bool(spec.get("status")) # 0 and None are both "not failed" + pred.set_color(PRED_FAIL_COLOR if failed else PRED_COLOR) + pred.set_linewidth(PRED_FAIL_LW if failed else PRED_LW) + banner = artists.get("qpfail") + if banner is not None: + banner.set_visible(failed) + if failed: + banner.set_text("QP FAIL (status=%d)" % int(spec["status"])) + + +# -------------------------------------------------------------------------- +# multi-run overlay +# -------------------------------------------------------------------------- + +#: Per-run colours, in assignment order. Chosen to stay distinguishable when +#: two bodies overlap at the configured alpha; cycled when runs outnumber them. +RUN_COLORS = ("#4c72b0", "#dd8452", "#55a868", "#c44e52", "#8172b3", + "#937860", "#da8bc3", "#8c8c8c", "#ccb974", "#64b5cd") +#: Default body fill alpha in overlay mode. Runs of the same scenario overlap +#: for most of their length, so opaque bodies would hide every run but the last. +BODY_ALPHA = 0.55 +#: Default driven-trail alpha. +PATH_ALPHA = 0.9 +#: Alpha of the faint full-route line drawn once per run under everything else. +GHOST_ALPHA = 0.18 +#: Alpha multiplier for a run whose trace has already ended. +ENDED_ALPHA_SCALE = 0.35 +#: Fit-camera margin, as a fraction of the fitted span. +FIT_MARGIN_FRAC = 0.08 +#: Wheel outline once utilization crosses :data:`UTIL_WARN`. Overlay mode +#: spends body colour on run identity, so grip has to show up on the outline. +WHEEL_EDGE = "#1b2733" +WHEEL_EDGE_WARN = "#c02020" + + +def _unwrapped_interp(t_query, t, values): + """Interpolate an angle series [rad] across the ±π seam. + + Interpolating wrapped yaw directly would sweep the vehicle the long way + round whenever a frame time falls across the seam. + """ + return np.interp(t_query, t, np.unwrap(np.asarray(values, dtype=float))) + + +class MultiScene: + """Several traces resampled onto one common time base. + + Runs are recorded independently — different ``dt``, different durations, + possibly different vehicles — so an overlay cannot share a sample index. + Frame ``k`` is a *time*, and every run is interpolated at that time. A run + whose trace has ended holds its final pose and reports ``active = False`` + so the drawing code fades it instead of pretending it is still driving. + + :param paths: two or more ``.vdtrace`` paths. + :param labels: legend labels; defaults to run ids, falling back to stems. + :param colors: matplotlib colours; defaults to :data:`RUN_COLORS`. + :param fps: output frame rate. + :param speed: playback speed relative to wall-clock (2.0 = twice as fast). + :param follow: ``"fit"`` for a static window holding every run, or a run + index for a tracking camera locked to that run. + """ + + def __init__(self, paths, labels=None, colors=None, fps: int = 20, + speed: float = 1.0, follow="fit"): + paths = [Path(p) for p in paths] + if len(paths) < 2: + raise ValueError("overlay mode needs at least two traces, got %d" + % (len(paths),)) + step = float(speed) / float(max(int(fps), 1)) + if not step > 0.0: + raise ValueError("fps and speed must be positive (got fps=%r, speed=%r)" + % (fps, speed)) + + self.paths = paths + self.runs = [LoadedTrace(p) for p in paths] + self.labels = _run_labels(self.runs, paths, labels) + self.colors = _run_colors(len(self.runs), colors) + self.follow = self._follow_index(follow) + + self.t0 = min(float(r.t[0]) for r in self.runs) + self.t1 = max(float(r.t[-1]) for r in self.runs) + self.step = step + n = int(math.floor((self.t1 - self.t0) / step + 1e-9)) + 1 + self.times = self.t0 + step * np.arange(max(n, 1)) + self.tracks = [self.resample_run(r, self.times) for r in self.runs] + self.series_names = self._series_names() + self.xlim, self.ylim = self._fit_window() + + # -- construction helpers --------------------------------------------- + def _follow_index(self, follow): + """Validate ``follow``; ``None`` means the static fit-all window.""" + if follow is None or follow == "fit": + return None + try: + idx = int(follow) + except (TypeError, ValueError): + raise ValueError("--follow takes 'fit' or a run index, got %r" % (follow,)) + if not 0 <= idx < len(self.runs): + raise ValueError("--follow %d is out of range for %d runs" + % (idx, len(self.runs))) + return idx + + @staticmethod + def resample_run(run: LoadedTrace, times): + """Interpolate one run onto ``times``; hold the end pose past its end. + + :returns: dict of arrays, all of length ``len(times)``, plus ``active`` + (False once the run's own trace has ended). + """ + t = np.asarray(run.t, dtype=float) + util = np.column_stack([np.interp(times, t, run.util[:, w]) + for w in range(run.util.shape[1])]) + return { + "x": np.interp(times, t, run.pose[:, 0]), + "y": np.interp(times, t, run.pose[:, 1]), + "yaw": _unwrapped_interp(times, t, run.pose[:, 2]), + "steer": np.interp(times, t, run.steer), + "vx": np.interp(times, t, run.v_body[:, 0]), + "vy": np.interp(times, t, run.v_body[:, 1]), + "yaw_rate": np.interp(times, t, run.yaw_rate), + "util": util, + # np.interp clamps, so the pose past the end is the final pose. + "active": (times >= t[0] - 1e-9) & (times <= t[-1] + 1e-9), + } + + def _series_names(self): + """Command channels to panel, in :data:`PREFERRED_SERIES` order. + + The union across runs, not the intersection: a run that lacks a + channel simply contributes no curve to that panel. + """ + names = [] + for run in self.runs: + for name, _unit, _arr in run.series: + if name not in names: + names.append(name) + order = {n: i for i, n in enumerate(PREFERRED_SERIES)} + return sorted(names, key=lambda n: (order.get(n, len(order)), n)) + + def series_unit(self, name: str) -> str: + """Unit string for a panelled channel, from the first run carrying it.""" + for run in self.runs: + for n, unit, _arr in run.series: + if n == name: + return unit + return "" + + def series_of(self, run: LoadedTrace, name: str): + """That run's samples for a channel, or ``None`` when it lacks it.""" + for n, _unit, arr in run.series: + if n == name: + return arr + return None + + def _fit_window(self): + """Static square window covering every run's whole route. + + Square by construction: the BEV keeps ``aspect="equal"``, and a + non-square limit pair would otherwise be silently re-fitted by + matplotlib and stop matching what the spec reports. + """ + xs = np.concatenate([r.pose[:, 0] for r in self.runs]) + ys = np.concatenate([r.pose[:, 1] for r in self.runs]) + pad = max(max(r.body_l, r.body_w) for r in self.runs) + cx = 0.5 * (float(xs.min()) + float(xs.max())) + cy = 0.5 * (float(ys.min()) + float(ys.max())) + span = max(float(xs.max() - xs.min()), float(ys.max() - ys.min()), 1e-6) + half = 0.5 * span * (1.0 + 2.0 * FIT_MARGIN_FRAC) + pad + return (cx - half, cx + half), (cy - half, cy + half) + + # -- queries ----------------------------------------------------------- + @property + def n_frames(self) -> int: + """Number of overlay frames on the common time base.""" + return len(self.times) + + def window(self, k: int): + """``(xlim, ylim)`` for frame ``k`` — fit-all, or tracking ``follow``.""" + if self.follow is None: + return self.xlim, self.ylim + run, track = self.runs[self.follow], self.tracks[self.follow] + x, y = float(track["x"][k]), float(track["y"][k]) + half = run.view_half + return (x - half, x + half), (y - half, y + half) + + def spread(self, k: int) -> float: + """Largest distance [m] between any two runs at frame ``k``. + + The comparison's whole point is divergence, so this is what picks the + preview frame — an arbitrary frame would usually show the runs still + on top of each other. + """ + pts = [(float(tk["x"][k]), float(tk["y"][k])) for tk in self.tracks] + worst = 0.0 + for i in range(len(pts)): + for j in range(i + 1, len(pts)): + worst = max(worst, math.hypot(pts[i][0] - pts[j][0], + pts[i][1] - pts[j][1])) + return worst + + def preview_frame(self) -> int: + """Frame of maximum inter-run spread.""" + return int(np.argmax([self.spread(k) for k in range(self.n_frames)])) + + +def _run_labels(runs, paths, labels): + """Legend labels: explicit, else distinct run ids, else file stems.""" + if labels: + labels = list(labels) + if len(labels) != len(paths): + raise ValueError("got %d labels for %d traces" + % (len(labels), len(paths))) + return [str(x) for x in labels] + ids = [str(r.repro.get("run_id") or "") for r in runs] + if all(ids) and len(set(ids)) == len(ids): + return ids + stems = [p.stem for p in paths] + if len(set(stems)) == len(stems): + return stems + return ["%s [%d]" % (s, i) for i, s in enumerate(stems)] + + +def _run_colors(n: int, colors=None): + """``n`` run colours, cycling :data:`RUN_COLORS` when none are given.""" + if colors: + colors = list(colors) + if len(colors) < n: + raise ValueError("got %d colours for %d traces" % (len(colors), n)) + return [str(c) for c in colors[:n]] + return [RUN_COLORS[i % len(RUN_COLORS)] for i in range(n)] + + +def multi_frame_spec(scene: MultiScene, k: int) -> dict: + """Describe overlay frame ``k`` — pure function, no matplotlib, no I/O. + + :param scene: loaded scene. + :param k: index into :attr:`MultiScene.times`. + :returns: dict with the shared ``xlim``/``ylim`` and one entry per run + (``body``, ``wheels``, ``util``, ``arrow``, trail arrays, ``color``, + ``alpha_scale`` and the HUD row). + """ + k = int(k) + t = float(scene.times[k]) + xlim, ylim = scene.window(k) + runs = [] + for idx, (run, track) in enumerate(zip(scene.runs, scene.tracks)): + x, y = float(track["x"][k]), float(track["y"][k]) + yaw = float(track["yaw"][k]) + delta = float(track["steer"][k]) + vx, vy = float(track["vx"][k]), float(track["vy"][k]) + speed = math.hypot(vx, vy) + util = [float(u) for u in track["util"][k]] + active = bool(track["active"][k]) + body, wheels = body_and_wheels(run.geometry, run.body_l, run.body_w, + x, y, yaw, delta) + # The trail stops growing at the run's own end, so a finished run does + # not keep drawing a line while the others carry on. + last = k if active else int(np.count_nonzero(track["active"])) - 1 + last = max(last, 0) + runs.append({ + "index": idx, + "label": scene.labels[idx], + "color": scene.colors[idx], + "active": active, + "alpha_scale": 1.0 if active else ENDED_ALPHA_SCALE, + "x": x, "y": y, "yaw": yaw, "speed": speed, + "body": body, + "wheels": wheels, + "util": util, + "arrow": velocity_arrow(run.geometry, x, y, yaw, vx, vy), + "trail_x": track["x"][: last + 1], + "trail_y": track["y"][: last + 1], + "hud": "%-14.14s v %5.1f km/h util %4.2f%s" % ( + scene.labels[idx], speed * 3.6, + max(util) if util else 0.0, "" if active else " (ended)"), + }) + return { + "index": k, + "t": t, + "xlim": xlim, + "ylim": ylim, + "runs": runs, + "hud_time": "t = %6.2f s" % (t,), + } + + +def _setup_multi_axes(scene: MultiScene, figsize, dpi): + """Figure with the BEV on the left and one panel per channel + utilization.""" + n_panels = len(scene.series_names) + 1 + fig = plt.figure(figsize=figsize, dpi=dpi) + gs = GridSpec(n_panels, 2, figure=fig, width_ratios=[1.45, 1.0], + hspace=0.32, wspace=0.24, + left=0.07, right=0.975, top=0.90, bottom=0.10) + ax_bev = fig.add_subplot(gs[:, 0]) + ax_panels = [fig.add_subplot(gs[k, 1]) for k in range(n_panels)] + return fig, ax_bev, ax_panels + + +def _draw_multi_static(scene: MultiScene, ax_bev, ax_panels, alpha: float, + path_alpha: float, ghost: bool = True): + """Static overlay content plus one artist set per run.""" + ax_bev.set_aspect("equal", adjustable="box") + ax_bev.set_xlabel("X [m]") + ax_bev.set_ylabel("Y [m]") + ax_bev.grid(True, alpha=0.25, lw=0.5) + + # Scenario overlays are drawn once even when several runs carry the same + # ones: they describe the world, not the run. + seen = set() + for run in scene.runs: + for reg in run.region_overlays(): + key = ("region", reg.get("name")) + poly = reg.get("polygon") or [] + if key in seen or len(poly) < 3: + continue + seen.add(key) + ax_bev.add_collection(PolyCollection( + [poly], facecolors="#c44e52", alpha=0.16, + edgecolors="#c44e52", linewidths=0.8, zorder=0)) + for ref in run.path2d_overlays(): + key = ("path2d", ref.get("name")) + xy = np.asarray(ref.get("xy") or [], dtype=float) + if key in seen or not xy.size: + continue + seen.add(key) + ax_bev.plot(xy[:, 0], xy[:, 1], ls="--", lw=1.2, color="#555555", + alpha=0.8, zorder=1, label=ref.get("name", "reference")) + + artists = [] + for idx, run in enumerate(scene.runs): + color = scene.colors[idx] + if ghost: + ax_bev.plot(run.pose[:, 0], run.pose[:, 1], "-", lw=1.0, color=color, + alpha=GHOST_ALPHA, zorder=1) + (trail,) = ax_bev.plot([], [], "-", lw=1.6, color=color, + alpha=path_alpha, zorder=2 + idx, + label=scene.labels[idx]) + body = PolyCollection([], facecolors=color, edgecolors=WHEEL_EDGE, + linewidths=1.1, alpha=alpha, zorder=10 + idx) + ax_bev.add_collection(body) + wheels = PolyCollection([], facecolors=color, edgecolors=WHEEL_EDGE, + linewidths=0.9, zorder=30 + idx) + ax_bev.add_collection(wheels) + arrow = ax_bev.annotate( + "", xy=(0, 0), xytext=(0, 0), zorder=50 + idx, + arrowprops=dict(arrowstyle="-|>", color=color, lw=1.6, + shrinkA=0, shrinkB=0)) + hud = ax_bev.text( + 0.015, 0.975 - 0.045 * (idx + 1), "", transform=ax_bev.transAxes, + va="top", ha="left", fontsize=7.5, family="monospace", + color=color, zorder=100, + bbox=dict(boxstyle="round,pad=0.25", fc="white", ec="none", alpha=0.75)) + artists.append(dict(trail=trail, body=body, wheels=wheels, arrow=arrow, + hud=hud)) + + hud_time = ax_bev.text( + 0.015, 0.985, "", transform=ax_bev.transAxes, va="top", ha="left", + fontsize=8, family="monospace", color="#111111", zorder=100, + bbox=dict(boxstyle="round,pad=0.25", fc="white", ec="#8a97a5", alpha=0.85)) + ax_bev.legend(loc="lower left", fontsize=7, framealpha=0.6, ncol=1) + + for ax, name in zip(ax_panels, scene.series_names): + source_unit = scene.series_unit(name) + display_unit = source_unit + for idx, run in enumerate(scene.runs): + arr = scene.series_of(run, name) + if arr is None: + continue + display_unit, display_arr = series_for_display(source_unit, arr) + ax.plot(run.t, display_arr, lw=1.1, + color=scene.colors[idx], alpha=0.9) + ax.set_ylabel("%s [%s]" % (name, display_unit or "-"), fontsize=8) + ax_util = ax_panels[-1] + for idx, run in enumerate(scene.runs): + ax_util.plot(run.t, run.util.max(axis=1), lw=1.1, + color=scene.colors[idx], alpha=0.9) + ax_util.axhline(UTIL_WARN, color="#111111", lw=0.9, ls="--") + ax_util.set_ylabel("max util [-]", fontsize=8) + ax_util.set_ylim(0.0, UTIL_MAX) + + lines = [] + for ax in ax_panels: + ax.tick_params(labelsize=7) + ax.grid(True, alpha=0.25, lw=0.5) + ax.set_xlim(scene.t0, scene.t1) + for idx, run in enumerate(scene.runs): + for ev in run.event_overlays(): + for et in ev.get("t", []): + ax.axvline(float(et), color=scene.colors[idx], lw=0.8, + alpha=0.5, ls=":") + lines.append(ax.axvline(scene.t0, color="#111111", lw=1.0)) + ax_panels[-1].set_xlabel("time [s]", fontsize=8) + return dict(runs=artists, hud_time=hud_time, cursors=lines) + + +def draw_multi_frame(artists, ax_bev, spec: dict, alpha: float = BODY_ALPHA, + path_alpha: float = PATH_ALPHA): + """Apply a :func:`multi_frame_spec` description to the prepared artists.""" + ax_bev.set_xlim(*spec["xlim"]) + ax_bev.set_ylim(*spec["ylim"]) + artists["hud_time"].set_text(spec["hud_time"]) + for art, r in zip(artists["runs"], spec["runs"]): + scale = r["alpha_scale"] + art["trail"].set_data(r["trail_x"], r["trail_y"]) + art["trail"].set_alpha(path_alpha * scale) + art["body"].set_verts([r["body"]]) + art["body"].set_alpha(alpha * scale) + art["wheels"].set_verts(r["wheels"]) + art["wheels"].set_edgecolors( + [WHEEL_EDGE_WARN if u >= UTIL_WARN else WHEEL_EDGE for u in r["util"]]) + art["wheels"].set_alpha(min(1.0, alpha + 0.3) * scale) + x, y, dx, dy = r["arrow"] + art["arrow"].set_position((x, y)) + art["arrow"].xy = (x + dx, y + dy) + art["arrow"].set_alpha(scale) + art["hud"].set_text(r["hud"]) + art["hud"].set_alpha(0.45 + 0.55 * scale) + for cur in artists["cursors"]: + cur.set_xdata([spec["t"], spec["t"]]) + + +def render_multi(trace_paths, out=None, png=None, fps: int = 20, + speed: float = 1.0, dpi: int = 100, figsize=(11.0, 5.6), + mp4: bool = False, title: str = None, labels=None, colors=None, + alpha: float = BODY_ALPHA, path_alpha: float = PATH_ALPHA, + follow="fit", ghost: bool = True, quiet: bool = False) -> dict: + """Render several traces overlaid in one BEV animation. + + :param trace_paths: two or more ``.vdtrace`` paths. + :param out: output GIF path; defaults to ``_overlay.gif``. + :param png: preview PNG path; defaults to ``_preview.png``. + :param fps: output frame rate. + :param speed: playback speed relative to wall-clock. + :param dpi: figure DPI. + :param figsize: figure size in inches. + :param mp4: also write an MP4 when ``imageio-ffmpeg`` is installed. + :param title: figure suptitle. + :param labels: per-run legend labels. + :param colors: per-run colours. + :param alpha: body fill alpha — lower it when runs overlap heavily. + :param path_alpha: driven-trail alpha. + :param follow: ``"fit"`` (static window over every run) or a run index. + :param ghost: draw each run's full route faintly under the animation. + :param quiet: suppress the progress line. + :returns: dict with ``gif``, ``png``, ``mp4``, ``frames``, ``runs``, + ``labels``, ``max_spread_m`` and ``wall_s``. + """ + t_start = time.time() + trace_paths = [Path(p) for p in trace_paths] + out = Path(out) if out else trace_paths[0].with_name( + trace_paths[0].stem + "_overlay.gif") + png = Path(png) if png else out.with_name(out.stem + "_preview.png") + + scene = MultiScene(trace_paths, labels=labels, colors=colors, fps=fps, + speed=speed, follow=follow) + if scene.n_frames == 0: + raise ValueError("no overlay frames: traces carry no samples") + + fig, ax_bev, ax_panels = _setup_multi_axes(scene, figsize, dpi) + fig.suptitle(title or _default_multi_title(scene), fontsize=9) + artists = _draw_multi_static(scene, ax_bev, ax_panels, alpha, path_alpha, + ghost=ghost) + + def _update(k): + draw_multi_frame(artists, ax_bev, multi_frame_spec(scene, k), + alpha=alpha, path_alpha=path_alpha) + return () + + peak = scene.preview_frame() + draw_multi_frame(artists, ax_bev, multi_frame_spec(scene, peak), + alpha=alpha, path_alpha=path_alpha) + png.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(png), dpi=dpi) + + anim = FuncAnimation(fig, _update, frames=scene.n_frames, + interval=1000 // max(fps, 1), blit=False, repeat=True) + out.parent.mkdir(parents=True, exist_ok=True) + anim.save(str(out), writer=PillowWriter(fps=fps)) + + mp4_path = None + if mp4: + try: + import imageio_ffmpeg # noqa: F401 + from matplotlib.animation import FFMpegWriter + mp4_path = out.with_suffix(".mp4") + matplotlib.rcParams["animation.ffmpeg_path"] = \ + imageio_ffmpeg.get_ffmpeg_exe() + anim.save(str(mp4_path), writer=FFMpegWriter(fps=fps)) + except ImportError: + mp4_path = None + if not quiet: + print("mp4 skipped: imageio-ffmpeg not installed (gif written)") + plt.close(fig) + + wall = time.time() - t_start + result = {"gif": out, "png": png, "mp4": mp4_path, + "frames": scene.n_frames, "runs": len(scene.runs), + "labels": list(scene.labels), + "max_spread_m": float(scene.spread(peak)), + "wall_s": wall, + "trace_duration_s": float(scene.t1 - scene.t0)} + if not quiet: + print("wrote %s (%d runs, %d frames, %.1f fps, max spread %.2f m) " + "+ %s in %.2fs wall-clock" + % (out, len(scene.runs), scene.n_frames, fps, + result["max_spread_m"], png.name, wall)) + return result + + +def _default_multi_title(scene: MultiScene) -> str: + return "VDSim overlay — %d runs: %s" % ( + len(scene.runs), ", ".join(scene.labels)) + + +# -------------------------------------------------------------------------- +# entry point +# -------------------------------------------------------------------------- + +def render(trace_path, out=None, png=None, fps: int = 20, stride: int = None, + dpi: int = 100, figsize=(11.0, 5.6), mp4: bool = False, + title: str = None, quiet: bool = False, sidecar=None, + preview_frame: str = "util-peak", view_half: float = None, + preset=None, panels=None) -> dict: + """Render one trace to a GIF (+ preview PNG). + + :param trace_path: input ``.vdtrace``. + :param out: output GIF path; defaults to the trace path with ``.gif``. + :param png: preview PNG path; defaults to ``_preview.png``. + :param fps: output frame rate. + :param stride: recorded samples per output frame. ``None`` derives it from + the record rate so the animation plays at wall-clock speed. + :param dpi: figure DPI. + :param figsize: figure size in inches. + :param mp4: also write an MP4; requires ``imageio-ffmpeg`` and is skipped + with a warning when it is unavailable. + :param title: figure suptitle. + :param quiet: suppress the progress line. + :param sidecar: explicit horizons ``.qp.npz``; ``None`` auto-detects + ``.qp.npz`` and renders without horizons when absent. + :param preview_frame: ``"util-peak"`` (default, unchanged behaviour) or + ``"first-fail"`` to put the preview on the first failed solve. + :param view_half: BEV half-window [m]. ``None`` keeps the geometry-derived + tracking window, which is ~6 wheelbases and therefore much shorter + than a typical MPC horizon — widen it to put the horizons on screen. + Overrides the preset, per §6.3's CLI-first resolution order. + :param preset: built-in preset name, path to a user preset file, or a + preset dict. ``None`` uses ``overview``. + :param panels: channel names replacing the preset's panels. + :returns: dict with ``gif``, ``png``, ``mp4``, ``firstfail_png``, + ``preset``, ``layout_violations``, ``n_qp_fail``, ``frames`` and + ``wall_s``. + """ + resolved = vdsim_preset.apply_overrides( + vdsim_preset.load_preset(preset), panels=panels, view_half=view_half) + if preview_frame not in ("util-peak", "first-fail"): + raise ValueError("preview_frame must be util-peak|first-fail, got %r" + % (preview_frame,)) + t_start = time.time() + trace_path = Path(trace_path) + out = Path(out) if out else trace_path.with_suffix(".gif") + png = Path(png) if png else out.with_name(out.stem + "_preview.png") + + if stride is None: + with TraceReader(trace_path) as probe: + dt = float(probe.repro.get("dt_s") or 0.0) + stride = max(1, int(round(1.0 / (fps * dt)))) if dt > 0 else 1 + tr = LoadedTrace(trace_path, stride=stride, sidecar=sidecar, + panels=resolved["panels"]) + if tr.n_frames == 0: + raise ValueError("trace has no samples to render: %s" % (trace_path,)) + + preset_half = resolved["bev"].get("view_half_m") + if preset_half is not None: + tr.view_half_used = float(preset_half) * (1.0 + VIEW_MARGIN_FRAC) + elif tr.horizon_reach > tr.view_half and not quiet: + print("note: horizons reach %.0f m but the window is +-%.0f m; pass --view-half to widen" + % (tr.horizon_reach, tr.view_half)) + + fig, ax_bev, ax_series = _setup_axes(tr, figsize, dpi) + fig.suptitle(title or _default_title(tr), fontsize=9) + artists = _draw_static(tr, ax_bev, ax_series, bev=resolved["bev"]) + + def _update(k): + draw_frame(artists, ax_bev, frame_spec(tr, int(tr.frames[k]))) + return () + + # Preview PNG at peak utilization: the frame where the 8 required screen + # items are all actually exercised, rather than an arbitrary first frame. + # ``first-fail`` moves it to the first failed solve instead, which is the + # frame a QP-failure post-mortem actually wants. + peak = int(tr.frames[int(np.argmax(tr.util[tr.frames].max(axis=1)))]) + first_fail = tr.first_fail_frame() + chosen = peak + if preview_frame == "first-fail": + if first_fail is None: + if not quiet: + print("preview: no failed solve in this run; using util-peak") + else: + chosen = first_fail + draw_frame(artists, ax_bev, frame_spec(tr, chosen)) + png.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(str(png), dpi=dpi) + # Checked on the preview frame, which carries every text element the + # animation ever shows (HUD, legend, suptitle, panel labels). + violations = layout_violations(fig) + if violations and not quiet: + print("layout: %d element(s) left the frame:\n %s" + % (len(violations), "\n ".join(violations))) + + # The first failed solve always gets its own PNG when there is one, so + # the evidence frame does not depend on the preview mode asked for. + fail_png = None + if first_fail is not None: + fail_png = out.with_name(out.stem + "_firstfail.png") + draw_frame(artists, ax_bev, frame_spec(tr, first_fail)) + fig.savefig(str(fail_png), dpi=dpi) + + anim = FuncAnimation(fig, _update, frames=tr.n_frames, + interval=1000 // max(fps, 1), blit=False, repeat=True) + out.parent.mkdir(parents=True, exist_ok=True) + anim.save(str(out), writer=PillowWriter(fps=fps)) + + mp4_path = None + if mp4: + try: + import imageio_ffmpeg # noqa: F401 + from matplotlib.animation import FFMpegWriter + mp4_path = out.with_suffix(".mp4") + matplotlib.rcParams["animation.ffmpeg_path"] = \ + imageio_ffmpeg.get_ffmpeg_exe() + anim.save(str(mp4_path), writer=FFMpegWriter(fps=fps)) + except ImportError: + mp4_path = None + if not quiet: + print("mp4 skipped: imageio-ffmpeg not installed (gif written)") + plt.close(fig) + + wall = time.time() - t_start + result = {"gif": out, "png": png, "mp4": mp4_path, + "firstfail_png": fail_png, + "preset": resolved["name"], + "panels": [n for n, _u, _a in tr.series], + "layout_violations": violations, + "view_half_m": float(tr.view_half_used), + "horizon_reach_m": float(tr.horizon_reach), + "n_qp_fail": int(artists.get("n_qp_fail", 0)), + "frames": tr.n_frames, "wall_s": wall, + "trace_duration_s": float(tr.t[-1] - tr.t[0])} + if not quiet: + print("wrote %s (%d frames, %.1f fps) + %s in %.2fs wall-clock" + % (out, tr.n_frames, fps, png.name, wall)) + return result + + +#: Slack allowed when checking that nothing escaped the frame [px]. +LAYOUT_TOLERANCE_PX = 1.0 + + +def layout_violations(fig, tol: float = LAYOUT_TOLERANCE_PX): + """Names of drawn elements that fall outside the figure frame. + + §6.3 allows data autoscale but treats text, gauges or panels leaving the + frame as a failure. Eyeballing a GIF does not catch that reliably, so the + check runs on the drawn figure and is reported by :func:`render`. + + Must be called after a draw: extents of text are only known once the + renderer has laid it out. + + :param fig: a drawn matplotlib figure. + :param tol: slack in pixels, absorbing rounding at the frame edge. + :returns: list of human-readable descriptions; empty means the frame holds. + """ + canvas = fig.canvas + canvas.draw() + renderer = canvas.get_renderer() + frame = fig.bbox + bad = [] + + def _check(label, bbox): + if bbox is None or bbox.width <= 0 or bbox.height <= 0: + return + if (bbox.x0 < frame.x0 - tol or bbox.y0 < frame.y0 - tol + or bbox.x1 > frame.x1 + tol or bbox.y1 > frame.y1 + tol): + bad.append("%s outside frame: [%.1f, %.1f, %.1f, %.1f] vs [%.1f, %.1f]" + % (label, bbox.x0, bbox.y0, bbox.x1, bbox.y1, + frame.x1, frame.y1)) + + for i, ax in enumerate(fig.axes): + _check("axes %d" % i, ax.get_window_extent(renderer)) + for j, txt in enumerate(ax.texts): + if txt.get_visible() and txt.get_text(): + _check("axes %d text %d" % (i, j), txt.get_window_extent(renderer)) + legend = ax.get_legend() + if legend is not None and legend.get_visible(): + _check("axes %d legend" % i, legend.get_window_extent(renderer)) + for j, txt in enumerate(fig.texts): + if txt.get_visible() and txt.get_text(): + _check("figure text %d" % j, txt.get_window_extent(renderer)) + return bad + + +def _default_title(tr: LoadedTrace) -> str: + rep = tr.repro + return "VDSim trace — run %s | %s | %s" % ( + rep.get("run_id", "?"), rep.get("param_hash", "")[:19], + rep.get("git_sha", "")[:9]) + + +def main(argv=None) -> int: + """CLI entry point (``vdsim-render``).""" + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("trace", type=Path, nargs="*", + help="input .vdtrace; two or more are overlaid in one view") + ap.add_argument("--out", type=Path, default=None, help="output GIF path") + ap.add_argument("--png", type=Path, default=None, help="preview PNG path") + ap.add_argument("--fps", type=int, default=20) + ap.add_argument("--stride", type=int, default=None, + help="single-run only: recorded samples per frame " + "(default: wall-clock speed)") + ap.add_argument("--dpi", type=int, default=100) + ap.add_argument("--mp4", action="store_true", + help="also write MP4 (needs imageio-ffmpeg)") + ap.add_argument("--title", default=None) + ap.add_argument("--sidecar", type=Path, default=None, + help="single-run only: .qp.npz carrying the MPC target " + "and prediction horizons " + "(default: .qp.npz when it exists)") + ap.add_argument("--view-half", dest="view_half", type=float, default=None, + help="single-run only: BEV half-window [m]. The default " + "tracking window is ~6 wheelbases, far shorter than " + "an MPC horizon, so widen it to see target/prediction") + ap.add_argument("--preset", default=None, + help="single-run only: render preset — a built-in name " + "(%s) or a path to a .yaml/.yml/.json preset file " + "(default: overview)" + % ", ".join(vdsim_preset.builtin_names())) + ap.add_argument("--panels", default=None, + help="single-run only: comma-separated panel channels " + "replacing the preset's (e.g. speed,u_steer). " + "Overrides the preset file") + ap.add_argument("--list-presets", action="store_true", + help="print the built-in presets and exit") + ap.add_argument("--preview-frame", dest="preview_frame", + default="util-peak", + choices=("util-peak", "first-fail"), + help="frame shown in the preview PNG " + "(default: %(default)s)") + grp = ap.add_argument_group("overlay mode (2+ traces)") + grp.add_argument("--labels", default=None, + help="comma-separated legend labels, one per trace " + "(default: run ids, else file stems)") + grp.add_argument("--colors", default=None, + help="comma-separated matplotlib colours, one per trace") + grp.add_argument("--alpha", type=float, default=BODY_ALPHA, + help="body fill alpha, for overlapping vehicles " + "(default: %(default)s)") + grp.add_argument("--path-alpha", type=float, default=PATH_ALPHA, + help="driven-path alpha (default: %(default)s)") + grp.add_argument("--follow", default="fit", + help="'fit' (static window holding every run) or a run " + "index to track (default: %(default)s)") + grp.add_argument("--speed", type=float, default=1.0, + help="playback speed vs wall-clock (default: %(default)s)") + grp.add_argument("--no-ghost", action="store_true", + help="do not draw each run's full route faintly") + args = ap.parse_args(argv) + + if args.list_presets: + for name in vdsim_preset.builtin_names(): + print("%-12s %s" + % (name, vdsim_preset.BUILTIN_PRESETS[name]["description"])) + return 0 + if not args.trace: + ap.error("at least one .vdtrace is required") + + if len(args.trace) == 1: + render(args.trace[0], out=args.out, png=args.png, fps=args.fps, + stride=args.stride, dpi=args.dpi, mp4=args.mp4, + title=args.title, sidecar=args.sidecar, + preview_frame=args.preview_frame, view_half=args.view_half, + preset=args.preset, panels=_split_list(args.panels)) + return 0 + + if args.stride is not None: + print("note: --stride is ignored in overlay mode; use --speed instead") + render_multi(args.trace, out=args.out, png=args.png, fps=args.fps, + speed=args.speed, dpi=args.dpi, mp4=args.mp4, title=args.title, + labels=_split_list(args.labels), colors=_split_list(args.colors), + alpha=args.alpha, path_alpha=args.path_alpha, + follow=args.follow, ghost=not args.no_ghost) + return 0 + + +def _split_list(value): + """``"a,b"`` -> ``["a", "b"]``; ``None``/empty -> ``None``.""" + if not value: + return None + return [part.strip() for part in str(value).split(",") if part.strip()] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/vdsim_trace.py b/python/vdsim_trace.py new file mode 100644 index 00000000..87e2e26c --- /dev/null +++ b/python/vdsim_trace.py @@ -0,0 +1,656 @@ +"""vdsim_trace — ``.vdtrace`` container I/O (writer, reader, overlay attach). + +A trace is the time-series record of **one** simulation run, stored as a single +zip container so that a renderer can reproduce the run's animation from that +file alone — no ``results.json`` re-parse, no re-running the simulation. + +Layout (zip, ``ZIP_STORED`` so channels stay ``mmap``/``frombuffer`` friendly):: + + run.vdtrace + |-- manifest.json # schema / metadata / channel table + |-- channels/.f64 # C-order little-endian float64 raw + +-- overlays/.json # externally injected, pass-through only + +Design contract (``11_trace_contract_spec.md``, source of truth): + +* Recording and rendering are separated — the renderer's only input is the file. +* VDSim records **plant** concepts only. Reference paths, lateral error and + pass/fail verdicts are not plant concepts; they arrive as overlays (§4) and + VDSim never interprets them. +* A trace declares its ``role`` (``plant`` or ``predictor``) so the same code + used as both the simulator under test and an optimiser's internal model + cannot be presented as one undeclared thing. +* The schema is independent of the tyre model. Model-dependent information + leaks out through exactly two manifest constants, ``tire.mu_aniso``. +* Friction utilization is a *derived* value computed by the consumer + (:func:`utilization`), never a recorded channel. + +This module depends on the standard library and numpy only. It deliberately +does **not** import ``vdsim`` (the compiled core), so a trace can be read and +rendered on a machine that cannot run the simulator. +""" +from __future__ import annotations + +import hashlib +import io +import json +import os +import warnings +import zipfile +from pathlib import Path + +import numpy as np + +#: Container schema version written by this module. ``0.x`` is unstable — +#: see the release notes. ``0.2`` added the required ``role`` field (§3.1). +SCHEMA_VERSION = "0.2" + +#: ``0.x`` minors this reader accepts. Each ``0.x`` minor is its own line, so +#: readability is opt-in rather than inferred: ``0.1`` stays readable only +#: because :func:`_resolve_role` defines a fallback for its missing ``role``. +READABLE_SCHEMA_VERSIONS = ("0.1", "0.2") + +#: Declared role of the run a trace records (§3.1, decided 2026-09-02). +#: ``plant`` is the simulator under verification; ``predictor`` is the same +#: code used as the internal model of an optimiser/MPC. The pair +#: ``role`` + ``param_hash`` is what makes an *undeclared* dual role — a run +#: whose plant and predictor are the same parameters presented as a +#: verification result — mechanically detectable. +ROLES = ("plant", "predictor") + +#: Role assumed for a ``0.1`` trace, which predates the field. +LEGACY_DEFAULT_ROLE = "plant" + +#: Wheel order used by every per-wheel channel. +WHEELS = ("FL", "FR", "RL", "RR") + +_MANIFEST_NAME = "manifest.json" +_CHANNEL_DIR = "channels" +_OVERLAY_DIR = "overlays" +_DTYPE = np.dtype(" (unit, trailing shape). +#: A channel's stored shape is ``(n_steps,) + trailing``. Adding channels is +#: backwards compatible; consumers must ignore channels they do not know. +CHANNEL_SPECS = { + "t": ("s", ()), + "pose": ("m,m,rad", (3,)), + "v_body": ("m/s", (2,)), + "yaw_rate": ("rad/s", ()), + "u_steer": ("rad", ()), + "u_fx": ("N", ()), + "wheel_F": ("N", (4, 3)), + "wheel_mu": ("-", (4,)), + "wheel_kappa": ("-", (4,)), + "wheel_alpha": ("rad", (4,)), +} + +#: Overlay kinds the container validates. Unknown kinds are stored verbatim +#: (a renderer must ignore them); a missing ``kind`` is rejected. +OVERLAY_KINDS = ("path2d", "timeseries", "event", "region") + +_REQUIRED_MANIFEST_KEYS = ( + "schema_version", "producer", "repro", "n_steps", + "wheels", "geometry", "tire", "channels", +) +_REQUIRED_REPRO_KEYS = ("vdsim_version", "git_sha", "param_hash", "seed", "dt_s", "run_id") +_REQUIRED_GEOMETRY_KEYS = ("wheelbase_m", "track_m", "steer_ratio") + + +class TraceError(ValueError): + """Base class for every ``.vdtrace`` contract violation.""" + + +class TraceSchemaError(TraceError): + """Manifest is missing required fields or declares an incompatible schema.""" + + +class TraceOverlayError(TraceError): + """Overlay object violates the §4 pass-through contract.""" + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + +def _canonical_json(obj) -> str: + """Serialise ``obj`` deterministically (stable key order, no whitespace drift).""" + return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def param_hash(plant_params: dict) -> str: + """Hash the **plant** parameter set that defines the input/output relation. + + Scope is fixed by the contract (§10.2): vehicle geometry/mass, tyre + parameters, road friction and the integration step are included; scenario + definitions, reference paths, controller gains and overlays are excluded. + Two runs of the same vehicle over different scenarios therefore share a + hash, which is what makes them comparable. + + :param plant_params: JSON-serialisable plant parameters. + :returns: ``"sha256:"``. + """ + digest = hashlib.sha256(_canonical_json(plant_params).encode("utf-8")).hexdigest() + return "sha256:" + digest + + +def _schema_compatible(found: str) -> bool: + """Return True if a trace written at ``found`` can be read by this module. + + ``0.x`` is an unstable line, so compatibility there requires an exact + match. From ``1.0`` on, a matching major version is enough. + """ + try: + f_major, f_minor = (int(p) for p in str(found).split(".")[:2]) + c_major, c_minor = (int(p) for p in SCHEMA_VERSION.split(".")[:2]) + except (ValueError, TypeError): + return False + if f_major != c_major: + return False + if c_major == 0: + return "%d.%d" % (f_major, f_minor) in READABLE_SCHEMA_VERSIONS + return f_minor <= c_minor + + +def _resolve_role(manifest) -> str: + """Return the run's declared role, applying the ``0.1`` fallback. + + ``role`` became required in schema ``0.2``. A ``0.1`` trace predates the + field, so a missing ``role`` there resolves to :data:`LEGACY_DEFAULT_ROLE` + with one warning; the same omission at ``0.2`` is an error, because a + silent default would reintroduce exactly the undeclared dual role the + field exists to expose. + + :param manifest: parsed ``manifest.json``. + :returns: one of :data:`ROLES`. + :raises TraceSchemaError: on a missing ``role`` at ``0.2``+ or an + unknown role value at any version. + """ + role = manifest.get("role") + if role is None: + if str(manifest.get("schema_version")) == "0.1": + warnings.warn( + "trace schema_version 0.1 declares no 'role'; assuming %r. " + "Re-record with vdsim_trace %s so the run states whether it " + "was the plant or a predictor." + % (LEGACY_DEFAULT_ROLE, SCHEMA_VERSION), + UserWarning, stacklevel=3) + return LEGACY_DEFAULT_ROLE + raise TraceSchemaError( + "manifest is missing 'role' — schema %s requires one of %s so a " + "plant run and a predictor run of the same code cannot be " + "presented as the same evidence" + % (manifest.get("schema_version"), list(ROLES))) + if role not in ROLES: + raise TraceSchemaError( + "manifest.role must be one of %s, got %r" % (list(ROLES), role)) + return role + + +def validate_overlay(obj) -> str: + """Validate an overlay's envelope only; never its content. + + The container checks that ``kind`` and ``name`` exist and that ``kind`` is a + string. It does not interpret coordinates, units or semantics — the moment + it did, "a channel called ref_path is drawn dashed" would become a rule and + the scenario coupling this contract removes would be back. + + :param obj: overlay dict. + :returns: the overlay name. + :raises TraceOverlayError: when the envelope is invalid. + """ + if not isinstance(obj, dict): + raise TraceOverlayError("overlay must be a JSON object") + kind = obj.get("kind") + if not kind or not isinstance(kind, str): + raise TraceOverlayError( + "overlay requires a non-empty string 'kind' tag " + "(one of %s, or a future kind renderers will ignore)" % (", ".join(OVERLAY_KINDS),)) + name = obj.get("name") + if not name or not isinstance(name, str): + raise TraceOverlayError("overlay requires a non-empty string 'name'") + if any(c in name for c in "/\\"): + raise TraceOverlayError("overlay name must not contain path separators: %r" % (name,)) + return name + + +def utilization(wheel_F, wheel_mu, mu_aniso, eps: float = 1.0) -> np.ndarray: + """Friction utilization per wheel — the derived quantity of §3.3. + + ``util = sqrt((Fx / (k_lon*mu*Fz))^2 + (Fy / (k_lat*mu*Fz))^2)``; ``1.0`` + means the tyre is on its friction limit. The friction circle is the special + case ``mu_aniso == [1, 1]``, so no circle/ellipse branch is needed. + + This is not a channel on purpose: the definition depends on how the tyre + model handles combined slip, and freezing it into the schema would break + the schema whenever the tyre model changes. + + :param wheel_F: ``[n,4,3]`` contact forces (Fx, Fy, Fz) in N. + :param wheel_mu: ``[n,4]`` road friction coefficient used that step. + :param mu_aniso: ``(k_lon, k_lat)`` mu multipliers from manifest ``tire``. + :param eps: floor applied to Fz [N] so an airborne wheel cannot divide by zero. + :returns: ``[n,4]`` utilization. + """ + F = np.asarray(wheel_F, dtype=float) + mu = np.asarray(wheel_mu, dtype=float) + k_lon, k_lat = (float(mu_aniso[0]), float(mu_aniso[1])) + Fz = np.maximum(F[..., 2], eps) + denom = mu * Fz + denom = np.where(denom > eps, denom, eps) + nx = F[..., 0] / (k_lon * denom) + ny = F[..., 1] / (k_lat * denom) + return np.sqrt(nx * nx + ny * ny) + + +# -------------------------------------------------------------------------- +# writer +# -------------------------------------------------------------------------- + +class TraceWriter: + """Accumulates channel samples and writes one ``.vdtrace`` on :meth:`finalize`. + + The writer owns the *simulation* write path only. Overlays are attached + afterwards through :func:`attach_overlay`; keeping the two paths apart is + the practical guarantee that VDSim never computes overlay content. + + :param path: output ``.vdtrace`` path. + :param geometry: manifest ``geometry`` block (``wheelbase_m``, ``track_m``, + ``steer_ratio`` required; body dimensions optional but recommended). + :param tire: manifest ``tire`` block (``friction_shape``, ``mu_aniso``). + :param repro: reproduction block; ``vdsim_version``, ``git_sha``, + ``param_hash``, ``seed``, ``dt_s``, ``run_id``. + :param producer: ``{"name": ..., "version": ...}`` of the writing script. + :param decimation: keep 1 of every ``N`` :meth:`append` calls (``N>=1``). + :param channels: channel subset to record; defaults to the full v0.1 table. + :param extra: additional manifest keys merged verbatim (never required). + :param role: keyword-only and **required** — one of :data:`ROLES`. It has + no default on purpose: the field exists to catch a producer that + silently records a predictor run as if it were the plant, and a + default would be that silence. + """ + + def __init__(self, path, geometry, tire, repro, producer=None, + decimation: int = 1, channels=None, extra=None, *, role): + decimation = int(decimation) + if decimation < 1: + raise TraceError("decimation must be >= 1, got %r" % (decimation,)) + names = tuple(channels) if channels is not None else tuple(CHANNEL_SPECS) + unknown = [n for n in names if n not in CHANNEL_SPECS] + if unknown: + raise TraceError("unknown channel(s): %s" % (", ".join(unknown),)) + _validate_geometry(geometry) + _validate_tire(tire) + if role not in ROLES: + raise TraceError("role must be one of %s, got %r" % (list(ROLES), role)) + + self.path = Path(path) + self.role = role + self.decimation = decimation + self.geometry = dict(geometry) + self.tire = dict(tire) + self.repro = dict(repro or {}) + self.producer = dict(producer or {"name": "unknown", "version": ""}) + self.extra = dict(extra or {}) + self._names = names + self._buf = {n: [] for n in names} + self._calls = 0 + self._finalized = False + + # -- recording --------------------------------------------------------- + @property + def n_steps(self) -> int: + """Number of samples actually stored so far (after decimation).""" + first = self._names[0] + return len(self._buf[first]) + + @property + def due(self) -> bool: + """True when the next :meth:`append` would be stored rather than dropped. + + Lets a producer skip building the sample at all on decimated steps — + the dict construction, not the storage, is what costs time in the + recording path. + """ + return (self._calls % self.decimation) == 0 + + def skip(self) -> None: + """Advance the decimation counter without storing a sample. + + Must be called exactly once per simulation step that was not appended, + otherwise the kept steps stop being evenly spaced. + """ + if self._finalized: + raise TraceError("skip() after finalize()") + self._calls += 1 + + def append(self, sample: dict) -> bool: + """Offer one simulation step to the trace. + + Every call advances the decimation counter; only every ``decimation``-th + call is stored. Recording 1 kHz physics at ``decimation=10`` therefore + yields exactly ``n_steps = calls / 10``. + + :param sample: mapping channel name -> value, covering every configured + channel. Shapes follow :data:`CHANNEL_SPECS`. + :returns: True when the sample was stored, False when decimated away. + """ + if self._finalized: + raise TraceError("append() after finalize()") + keep = (self._calls % self.decimation) == 0 + self._calls += 1 + if not keep: + return False + for name in self._names: + if name not in sample: + raise TraceError("sample is missing channel %r" % (name,)) + self._buf[name].append(sample[name]) + return True + + # -- output ------------------------------------------------------------ + def finalize(self) -> Path: + """Flush channels, freeze the manifest and write the container. + + :returns: the written path. + """ + if self._finalized: + raise TraceError("finalize() called twice") + n = self.n_steps + arrays = {} + chan_meta = [] + for name in self._names: + unit, trailing = CHANNEL_SPECS[name] + arr = np.asarray(self._buf[name], dtype=_DTYPE).reshape((n,) + trailing) + arrays[name] = np.ascontiguousarray(arr) + chan_meta.append({ + "name": name, "unit": unit, "dtype": " 0.0 for v in aniso)): + raise TraceSchemaError( + "manifest.tire.mu_aniso must be [k_lon, k_lat] with both > 0, got %r" % (aniso,)) + + +def _validate_manifest(manifest): + if not isinstance(manifest, dict): + raise TraceSchemaError("manifest.json must be an object") + missing = [k for k in _REQUIRED_MANIFEST_KEYS if k not in manifest] + if missing: + raise TraceSchemaError("manifest is missing required key(s): %s" % (", ".join(missing),)) + missing = [k for k in _REQUIRED_REPRO_KEYS if k not in manifest["repro"]] + if missing: + raise TraceSchemaError( + "manifest.repro is missing %s — without it 'deterministic replay' " + "is an unverifiable claim" % (", ".join(missing),)) + _validate_geometry(manifest["geometry"]) + _validate_tire(manifest["tire"]) + if not isinstance(manifest["channels"], list) or not manifest["channels"]: + raise TraceSchemaError("manifest.channels must be a non-empty list") + + +# -------------------------------------------------------------------------- +# reader +# -------------------------------------------------------------------------- + +class TraceReader: + """Read-only view over a ``.vdtrace``; channels load lazily. + + Usage:: + + with TraceReader("run.vdtrace") as tr: + pose = tr.channel("pose") # [n,3] + util = tr.utilization() # [n,4] + ref = tr.overlay("ref_path") # or None + + :param path: ``.vdtrace`` path. + :raises TraceSchemaError: on a version mismatch or a missing required block. + The schema is checked *before* any channel is parsed. + """ + + def __init__(self, path): + self.path = Path(path) + if not self.path.is_file(): + raise TraceError("trace not found: %s" % (self.path,)) + self._zf = zipfile.ZipFile(str(self.path), "r") + try: + raw = self._zf.read(_MANIFEST_NAME) + except KeyError: + self._zf.close() + raise TraceSchemaError("%s is not a .vdtrace: no manifest.json" % (self.path,)) + try: + manifest = json.loads(raw.decode("utf-8")) + except ValueError as exc: + self._zf.close() + raise TraceSchemaError("manifest.json is not valid JSON: %s" % (exc,)) + + found = manifest.get("schema_version") + if not _schema_compatible(found): + self._zf.close() + raise TraceSchemaError( + "trace schema_version %r is not readable by vdsim_trace %s " + "(0.x traces carry no compatibility guarantee); re-record the run " + "or use a matching VDSim release" % (found, SCHEMA_VERSION)) + try: + _validate_manifest(manifest) + role = _resolve_role(manifest) + except TraceError: + self._zf.close() + raise + self.manifest = manifest + self._role = role + self._cache = {} + + # -- lifecycle --------------------------------------------------------- + def close(self): + """Close the underlying zip handle.""" + self._zf.close() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + # -- metadata ---------------------------------------------------------- + @property + def n_steps(self) -> int: + """Number of recorded samples.""" + return int(self.manifest["n_steps"]) + + @property + def geometry(self) -> dict: + """Manifest ``geometry`` block.""" + return self.manifest["geometry"] + + @property + def tire(self) -> dict: + """Manifest ``tire`` block.""" + return self.manifest["tire"] + + @property + def repro(self) -> dict: + """Manifest ``repro`` block.""" + return self.manifest["repro"] + + @property + def role(self) -> str: + """Declared run role — ``plant`` or ``predictor`` (§3.1). + + Resolved once at open time, so a ``0.1`` trace reports the legacy + default here and warns exactly once rather than on every access. + """ + return self._role + + @property + def wheels(self): + """Wheel labels in per-wheel channel order.""" + return tuple(self.manifest.get("wheels", WHEELS)) + + def channel_names(self): + """Names of the channels this trace actually carries, in manifest order.""" + return tuple(c["name"] for c in self.manifest["channels"]) + + def channel_meta(self, name: str) -> dict: + """Manifest entry for ``name``. + + :raises KeyError: when the trace does not carry that channel. + """ + for c in self.manifest["channels"]: + if c["name"] == name: + return c + raise KeyError(name) + + def has(self, name: str) -> bool: + """True when the trace carries channel ``name``.""" + return any(c["name"] == name for c in self.manifest["channels"]) + + # -- data -------------------------------------------------------------- + def channel(self, name: str) -> np.ndarray: + """Load one channel as a read-only ``float64`` array of its manifest shape. + + Loading goes through ``np.frombuffer`` so the decode cost is a reshape, + not a parse. + """ + if name in self._cache: + return self._cache[name] + meta = self.channel_meta(name) + raw = self._zf.read("%s/%s.f64" % (_CHANNEL_DIR, name)) + shape = tuple(int(s) for s in meta["shape"]) + expect = int(np.prod(shape)) if shape else 0 + arr = np.frombuffer(raw, dtype=_DTYPE) + if arr.size != expect: + raise TraceError( + "channel %r has %d values but manifest declares shape %s" + % (name, arr.size, shape)) + arr = arr.reshape(shape) + arr.flags.writeable = False + self._cache[name] = arr + return arr + + def utilization(self) -> np.ndarray: + """Per-wheel friction utilization ``[n,4]`` — see :func:`utilization`.""" + return utilization(self.channel("wheel_F"), self.channel("wheel_mu"), + self.tire["mu_aniso"]) + + # -- overlays ---------------------------------------------------------- + def overlay_names(self): + """Names of attached overlays.""" + pre, suf = _OVERLAY_DIR + "/", ".json" + return tuple(sorted( + n[len(pre):-len(suf)] for n in self._zf.namelist() + if n.startswith(pre) and n.endswith(suf))) + + def overlay(self, name: str): + """Return one overlay object, or ``None`` when it is not attached.""" + try: + raw = self._zf.read("%s/%s.json" % (_OVERLAY_DIR, name)) + except KeyError: + return None + return json.loads(raw.decode("utf-8")) + + def overlays(self, kind=None): + """Return every overlay, optionally filtered by ``kind``. + + :param kind: overlay kind to keep, or ``None`` for all. + :returns: list of overlay dicts. + """ + out = [] + for name in self.overlay_names(): + obj = self.overlay(name) + if kind is None or (isinstance(obj, dict) and obj.get("kind") == kind): + out.append(obj) + return out + + +# -------------------------------------------------------------------------- +# overlay attach (post-simulation write path) +# -------------------------------------------------------------------------- + +def attach_overlay(path, obj, replace: bool = True) -> str: + """Attach one overlay to an existing ``.vdtrace``, after the run. + + VDSim stores the object and never interprets it. The envelope is checked + (:func:`validate_overlay`); unknown ``kind`` values are stored so that a + newer producer can write them and today's renderer can ignore them. + + :param path: existing ``.vdtrace``. + :param obj: overlay dict carrying at least ``kind`` and ``name``. + :param replace: overwrite an overlay of the same name when present. + :returns: the overlay name. + :raises TraceOverlayError: on an invalid envelope, or a name clash with + ``replace=False``. + """ + name = validate_overlay(obj) + p = Path(path) + member = "%s/%s.json" % (_OVERLAY_DIR, name) + payload = json.dumps(obj, sort_keys=True).encode("utf-8") + + with zipfile.ZipFile(str(p), "r") as zf: + existing = zf.namelist() + if member in existing and not replace: + raise TraceOverlayError("overlay %r already attached" % (name,)) + keep = [(info, zf.read(info.filename)) for info in zf.infolist() + if info.filename != member] + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_STORED) as out: + for info, data in keep: + out.writestr(info.filename, data) + out.writestr(member, payload) + tmp = p.with_suffix(p.suffix + ".part") + tmp.write_bytes(buf.getvalue()) + os.replace(str(tmp), str(p)) + return name diff --git a/scripts/check_validation_currency.py b/scripts/check_validation_currency.py new file mode 100644 index 00000000..dc88d69b --- /dev/null +++ b/scripts/check_validation_currency.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""Gate that keeps ``docs/VALIDATION.md`` from going stale. + +The document records one number -- the ctest result of the *canonical* +configuration (the ``validation`` CMake preset). Humans forget to refresh it, +so this script parses the machine-readable currency block out of the document +and compares every element against what was actually measured in CI. Any +mismatch is a hard failure, not a warning. + +Contract (VDSimMarketing ``07_p0_demo.md`` 6.3 / 6.5):: + + + tests: 407/407 + config: cmake --preset validation && ... + presets: CMakePresets.json@ + toolchain: cmake 3.31.10 + commit: + date: 2026-09-04 + excluded: gui_v3_e2e (reason -- link) + flaky: some_target (2/20 runs, unknown) + failing: other_target (reason) + +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +BEGIN_MARKER = "" +END_MARKER = "" + +#: Keys that may appear more than once inside the currency block. +MULTI_KEYS = ("excluded", "flaky", "failing") + +#: The elements every recorded number must carry: the four of +#: 07_p0_demo.md 6.3 plus the pinned toolchain (18_dev_briefing_0903.md 10, +#: Q1a). A count measured with a different CMake is a different measurement. +REQUIRED_KEYS = ("tests", "config", "commit", "date", "toolchain") + +#: Placeholder written in place of a measurement while the tree that the +#: last number belonged to no longer exists (integration merge). It is a +#: state, not a value: publishing the old number would be a stale claim and +#: inventing a new one without a canonical run would be a fabricated claim. +PENDING_TOKEN = "PENDING" + +_KV_RE = re.compile(r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$") +_TESTS_RE = re.compile(r"^(\d+)\s*/\s*(\d+)$") +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_SUMMARY_RE = re.compile( + r"(\d+)%\s+tests\s+passed,\s+(\d+)\s+tests\s+failed\s+out\s+of\s+(\d+)" +) +#: gtest value-parameterised names contain spaces ("... <00-00 00-00>"), +#: so the name runs to end of line rather than to the first blank. +_REGISTERED_RE = re.compile(r"^\s*Test\s*#\d+:\s*(.+?)\s*$") +_TOTAL_RE = re.compile(r"^\s*Total Tests:\s*(\d+)\s*$") +_CMAKE_VERSION_RE = re.compile(r"cmake\s+version\s+(\S+)", re.IGNORECASE) +_DOC_TOOLCHAIN_RE = re.compile(r"cmake\s+(\S+)", re.IGNORECASE) +_FENCE = chr(96) * 3 + +#: Historical measurements remain evidence rather than mutable status claims. +HISTORICAL_DOC_PREFIXES = ("tasks/", "evidence/") +HISTORICAL_DOC_PATHS = frozenset(( + "design/CURSOR_USAGE.md", + "design/L5_6DOF_MULTIBODY.md", + "design/LD4_MULTIBODY.md", + "design/LOW_SPEED_HANDLING.md", + "design/TIRE_INTERFACE_INVERSION.md", + "design/TIRE_ROADMAP.md", + "design/V0.2_PLAN.md", + "design/V0.2_SUBSYSTEMS.md", + "design/V0.4_PLAN.md", + "design/V0.4_SLOPE_JUMP_DYNAMICS.md", + "design/V0.5_TERRAIN_L5.md", +)) +HISTORICAL_DOC_NAMES = frozenset(( + "CLEANUP_PLAN.md", + "IMPROVEMENT_REPORT.md", + "PLANT_DELIVERY_NOTE.md", + "SUMMARY.md", + "TIRE_VALIDATION.md", +)) +_HARDCODED_TEST_COUNT_RE = re.compile( + r"(?:\b\d+\s*/\s*\d+\b.{0,24}\bctests?\b|" + r"\bctests?\b.{0,24}\b\d+\s*/\s*\d+\b|" + r"\b\d{2,}\b\*{0,2}\s+(?:automated\s+)?ctests?\b|" + r"\bctests?\b(?:\s+(?:suite|green|count|total))?\s*[:=]?\s*\*{0,2}\d{2,}\b)", re.IGNORECASE +) + + +def _is_historical_doc(relative): + """Return whether @p relative is an immutable report or evidence page.""" + posix = Path(relative).as_posix() + name = Path(posix).name + return (posix.startswith(HISTORICAL_DOC_PREFIXES) + or posix in HISTORICAL_DOC_PATHS + or name in HISTORICAL_DOC_NAMES + or name.startswith("HANDOFF") + or name.startswith("STATUS_")) + + +def parse_currency_block(text): + """Extract the currency block from a VALIDATION.md body. + + @param text Full markdown source of ``docs/VALIDATION.md``. + @return Mapping of key to value; ``MULTI_KEYS`` map to a list of + strings, every other key to a single string. + @throws ValueError If the begin/end markers are missing or out of order. + """ + start = text.find(BEGIN_MARKER) + end = text.find(END_MARKER) + if start < 0 or end < 0 or end < start: + raise ValueError( + "validation currency block not found; expected %r ... %r" + % (BEGIN_MARKER, END_MARKER) + ) + body = text[start + len(BEGIN_MARKER):end] + + parsed = {key: [] for key in MULTI_KEYS} + for raw_line in body.splitlines(): + line = raw_line.strip() + if not line or line.startswith(_FENCE) or line.startswith(" +tests: {tests} +config: cmake --preset validation && ctest --preset validation +presets: CMakePresets.json@{presets} +toolchain: cmake {toolchain} +commit: {commit} +date: {date} +{extra} + +Prose below the block. +""" + +CTEST_LIST = """Test #1: alpha + Test #2: beta + Test #3: gamma + +Total Tests: 3 +""" + +CTEST_RUN_ALL_PASS = """ +100% tests passed, 0 tests failed out of 3 + +Total Test time (real) = 1.00 sec +""" + +CTEST_RUN_ONE_FAIL = """ +66% tests passed, 1 tests failed out of 3 + +Total Test time (real) = 1.00 sec +""" + + +def make_doc(tests="3/3", presets="a" * 40, commit="b" * 40, + date="2026-09-04", extra="", toolchain="3.31.10"): + """Build a VALIDATION.md body with a currency block. + + @param tests Value for the ``tests:`` line. + @param presets Blob sha recorded on the ``presets:`` line. + @param commit Commit sha recorded on the ``commit:`` line. + @param date Value for the ``date:`` line. + @param extra Extra raw lines inserted before the end marker. + @param toolchain CMake version recorded on the ``toolchain:`` line. + @return Markdown source as a string. + """ + return DOC_TEMPLATE.format( + tests=tests, presets=presets, commit=commit, date=date, extra=extra, + toolchain=toolchain, + ) + + +class ParseCurrencyBlockTest(unittest.TestCase): + """The block parser must ignore surrounding prose and collect repeats.""" + + def test_reads_all_required_elements(self): + block = cvc.parse_currency_block(make_doc()) + self.assertEqual(block["tests"], "3/3") + self.assertEqual(block["date"], "2026-09-04") + self.assertIn("--preset validation", block["config"]) + self.assertEqual(block["commit"], "b" * 40) + self.assertEqual(block["toolchain"], "cmake 3.31.10") + + def test_collects_repeated_keys(self): + extra = ("excluded: gui_v3_e2e (deferred)\n" + "excluded: ergaccess (optional dep)\n" + "flaky: delta (2/20)\n") + block = cvc.parse_currency_block(make_doc(extra=extra)) + self.assertEqual(len(block["excluded"]), 2) + self.assertEqual(len(block["flaky"]), 1) + self.assertEqual(block["failing"], []) + + def test_missing_markers_raise(self): + with self.assertRaises(ValueError): + cvc.parse_currency_block("# no block here\n") + + +class ParseCtestOutputTest(unittest.TestCase): + """ctest output parsing must survive ctest's variable indentation.""" + + def test_registered_names(self): + self.assertEqual( + cvc.parse_registered_tests(CTEST_LIST), ["alpha", "beta", "gamma"] + ) + + def test_names_with_spaces_are_counted(self): + """gtest parameterised names embed spaces; they are still tests.""" + lf = chr(10) + listing = "".join([ + " Test #1: alpha" + lf, + " Test #2: Suite/Case.Name/0-byte object <00-00 00-00>" + lf, + "Total Tests: 2" + lf, + ]) + self.assertEqual( + cvc.parse_registered_tests(listing), + ["alpha", "Suite/Case.Name/0-byte object <00-00 00-00>"], + ) + + def test_run_summary(self): + self.assertEqual(cvc.parse_run_summary(CTEST_RUN_ALL_PASS), (3, 3)) + self.assertEqual(cvc.parse_run_summary(CTEST_RUN_ONE_FAIL), (2, 3)) + + def test_missing_summary_raises(self): + with self.assertRaises(ValueError): + cvc.parse_run_summary("no summary line at all") + + def test_cmake_version_is_read_from_the_banner(self): + banner = ("cmake version 3.31.10" + chr(10) + chr(10) + + "CMake suite maintained and supported by Kitware") + self.assertEqual(cvc.parse_cmake_version(banner), "3.31.10") + + def test_missing_cmake_version_raises(self): + with self.assertRaises(ValueError): + cvc.parse_cmake_version("not a cmake banner") + + +class PublicDocumentationCurrencyTest(unittest.TestCase): + """Live pages must not copy the canonical full-suite count.""" + + def setUp(self): + self.docs = Path(tempfile.mkdtemp(prefix="vdsim_docs_")) + self.addCleanup(shutil.rmtree, str(self.docs), True) + (self.docs / "index.md").write_text( + "Tests: see [Validation](VALIDATION.md).\n", encoding="utf-8" + ) + + def _write(self, relative, text): + path = self.docs / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def test_validation_link_without_count_passes(self): + self.assertEqual(cvc.hardcoded_test_counts(self.docs), []) + + def test_count_before_ctest_fails(self): + self._write("index.md", "Tests: **407 / 407 ctest** green.\n") + problems = cvc.hardcoded_test_counts(self.docs) + self.assertEqual(len(problems), 1) + self.assertIn("index.md:1", problems[0]) + + def test_single_count_before_ctest_fails(self): + self._write("ROADMAP.md", "Validation includes 382 ctests.\n") + problems = cvc.hardcoded_test_counts(self.docs) + self.assertEqual(len(problems), 1) + + def test_ctest_threshold_is_not_mistaken_for_suite_count(self): + self._write("ROADMAP.md", "ctest gate: pure-long ~2% and pure-lat ~1%.\n") + problems = cvc.hardcoded_test_counts(self.docs) + self.assertEqual(problems, []) + + def test_ctest_before_count_fails_on_nested_live_page(self): + self._write("guide/current.md", "Canonical ctest result: 407/407.\n") + problems = cvc.hardcoded_test_counts(self.docs) + self.assertEqual(len(problems), 1) + self.assertIn("guide/current.md:1", problems[0]) + + def test_validation_and_historical_reports_are_exempt(self): + self._write("VALIDATION.md", "tests: 407/407\n") + self._write("tasks/old.md", "Measured 12/12 ctest in 2025.\n") + self._write("evidence/run.md", "Suite: 15/15 tests.\n") + self._write("design/V0.2_PLAN.md", "All 9/9 ctest passed.\n") + self._write("HANDOFF_2025.md", "8/8 ctest green.\n") + self._write("STATUS_2025.md", "7/7 tests green.\n") + self.assertEqual(cvc.hardcoded_test_counts(self.docs), []) + + def test_new_design_page_is_not_implicitly_exempt(self): + self._write("design/current.md", "All 10/10 ctest passed.\n") + problems = cvc.hardcoded_test_counts(self.docs) + self.assertEqual(len(problems), 1) + self.assertIn("design/current.md:1", problems[0]) + + def test_missing_docs_tree_is_reported(self): + missing = self.docs / "absent" + self.assertEqual(len(cvc.hardcoded_test_counts(missing)), 1) + + +class CheckCurrencyTest(unittest.TestCase): + """The gate itself: each drift mode must produce a problem.""" + + def setUp(self): + self.registered = ["alpha", "beta", "gamma"] + self.commit = "b" * 40 + self.presets = "a" * 40 + + def run_gate(self, doc, registered=None, measured=(3, 3), + cmake_version="3.31.10"): + """Parse @p doc and run the gate against the measured numbers.""" + block = cvc.parse_currency_block(doc) + return cvc.check_currency( + block, + self.registered if registered is None else registered, + measured, + self.commit, + self.presets, + cmake_version=cmake_version, + ) + + def test_matching_document_passes(self): + self.assertEqual(self.run_gate(make_doc()), []) + + def test_pending_with_a_reason_passes(self): + """An integration merge voids the old count; PENDING is the honest state.""" + doc = make_doc(tests="PENDING", presets="PENDING", commit="PENDING", + date="PENDING", toolchain="PENDING", + extra="pending: tree replaced by an integration merge") + self.assertEqual(self.run_gate(doc, measured=(9, 9)), []) + + def test_pending_without_a_reason_fails(self): + """PENDING must not become a silent way to publish no number at all.""" + doc = make_doc(tests="PENDING", presets="PENDING", commit="PENDING", + date="PENDING", toolchain="PENDING") + problems = self.run_gate(doc, measured=(9, 9)) + self.assertTrue(any("pending:" in p for p in problems), problems) + + def test_pending_does_not_disable_the_count_drift_check(self): + """A real number is still compared; only the placeholder short-circuits.""" + problems = self.run_gate(make_doc(tests="4/4"), measured=(3, 3)) + self.assertTrue(any("count drift" in p for p in problems)) + + def test_total_drift_fails(self): + problems = self.run_gate(make_doc(tests="4/4"), measured=(3, 3)) + self.assertTrue(any("count drift" in p for p in problems)) + + def test_pass_count_drift_fails(self): + doc = make_doc(tests="3/3") + problems = self.run_gate(doc, measured=(2, 3)) + self.assertTrue(any("pass count drift" in p for p in problems)) + + def test_short_commit_prefix_is_accepted(self): + self.assertEqual(self.run_gate(make_doc(commit=self.commit[:9])), []) + + def test_unrelated_commit_fails(self): + original = cvc.commit_relation + cvc.commit_relation = lambda repo, doc, built: "unrelated" + try: + problems = self.run_gate(make_doc(commit="c" * 40)) + finally: + cvc.commit_relation = original + self.assertTrue(any("commit mismatch" in p for p in problems)) + + def test_ancestor_commit_passes(self): + original = cvc.commit_relation + cvc.commit_relation = lambda repo, doc, built: "ancestor" + try: + problems = self.run_gate(make_doc(commit="c" * 40)) + finally: + cvc.commit_relation = original + self.assertEqual(problems, []) + + def test_unknown_ancestry_does_not_fail(self): + # Shallow CI checkouts cannot resolve ancestry; the number comparison + # is the real gate, so unresolvable ancestry must not fail the build. + original = cvc.commit_relation + cvc.commit_relation = lambda repo, doc, built: "unknown" + try: + problems = self.run_gate(make_doc(commit="c" * 40)) + finally: + cvc.commit_relation = original + self.assertEqual(problems, []) + + def test_presets_sha_mismatch_fails(self): + problems = self.run_gate(make_doc(presets="d" * 40)) + self.assertTrue(any("sha mismatch" in p for p in problems)) + + def test_bad_date_fails(self): + problems = self.run_gate(make_doc(date="Sep 2026")) + self.assertTrue(any("date must be" in p for p in problems)) + + def test_missing_element_fails(self): + doc = make_doc().replace("date: 2026-09-04\n", "") + problems = self.run_gate(doc) + self.assertTrue(any("missing required element 'date:'" in p + for p in problems)) + + def test_excluded_target_still_registered_fails(self): + doc = make_doc(extra="excluded: beta (should be off)\n") + problems = self.run_gate(doc) + self.assertTrue(any("listed as excluded but registered" in p + for p in problems)) + + def test_excluded_target_absent_passes(self): + doc = make_doc(extra="excluded: gui_v3_e2e (deferred)\n") + self.assertEqual(self.run_gate(doc), []) + + def test_silent_failure_needs_a_flaky_or_failing_line(self): + doc = make_doc(tests="2/3") + problems = self.run_gate(doc, measured=(2, 3)) + self.assertTrue(any("no 'flaky:'/'failing:' line" in p + for p in problems)) + + doc_ok = make_doc(tests="2/3", extra="failing: gamma (known break)\n") + self.assertEqual(self.run_gate(doc_ok, measured=(2, 3)), []) + + def test_toolchain_drift_fails(self): + """A count measured with another CMake is another measurement.""" + problems = self.run_gate(make_doc(toolchain="4.0.3")) + self.assertTrue(any("toolchain drift" in p for p in problems)) + + def test_missing_toolchain_fails(self): + doc = make_doc().replace("toolchain: cmake 3.31.10\n", "") + problems = self.run_gate(doc) + self.assertTrue(any("missing required element 'toolchain:'" in p + for p in problems)) + + def test_toolchain_must_name_cmake(self): + doc = make_doc().replace("toolchain: cmake 3.31.10", + "toolchain: whatever the runner ships") + problems = self.run_gate(doc) + self.assertTrue(any("toolchain must name the pinned CMake" in p + for p in problems)) + + def test_unmeasured_toolchain_does_not_fail(self): + """Local runs without a version record still gate on the numbers.""" + self.assertEqual(self.run_gate(make_doc(), cmake_version=""), []) + + def test_ancestor_with_moved_registration_surface_fails(self): + """Ancestry alone must not carry a count across a CMake change.""" + original_rel = cvc.commit_relation + original_diff = cvc.registration_surface_diff + cvc.commit_relation = lambda repo, doc, built: "ancestor" + cvc.registration_surface_diff = ( + lambda repo, doc, built: ["tests/CMakeLists.txt"] + ) + try: + problems = self.run_gate(make_doc(commit="c" * 40)) + finally: + cvc.commit_relation = original_rel + cvc.registration_surface_diff = original_diff + self.assertTrue(any("registration surface changed" in p + for p in problems)) + + def test_identical_commit_skips_surface_check(self): + """Same sha cannot differ from itself; do not shell out for it.""" + called = [] + original_diff = cvc.registration_surface_diff + cvc.registration_surface_diff = ( + lambda repo, doc, built: called.append(1) or [] + ) + try: + self.assertEqual(self.run_gate(make_doc()), []) + finally: + cvc.registration_surface_diff = original_diff + self.assertEqual(called, []) + + def test_config_without_preset_fails(self): + doc = make_doc().replace( + "cmake --preset validation && ctest --preset validation", + "cmake -B build -DCMAKE_BUILD_TYPE=Release", + ) + problems = self.run_gate(doc) + self.assertTrue(any("must invoke the canonical CMake preset" in p + for p in problems)) + + +class RegistrationSurfaceDiffTest(unittest.TestCase): + """The helper runs real git; stubs elsewhere would hide a wrong pathspec.""" + + def setUp(self): + self.repo = Path(tempfile.mkdtemp(prefix="vdsim_surface_")) + self.addCleanup(shutil.rmtree, str(self.repo), True) + if shutil.which("git") is None: + self.skipTest("git not available") + self._git("init", "-q") + self._git("config", "user.email", "t@example.com") + self._git("config", "user.name", "t") + self._write("CMakeLists.txt", "add_test(NAME a COMMAND true)") + self._write("tests/CMakeLists.txt", "add_test(NAME b COMMAND true)") + self._write("cmake/helpers.cmake", "# helpers") + self._write("tests/test_body.cpp", "int main() { return 0; }") + self._git("add", "-A") + self._git("commit", "-qm", "base") + self.base = self._git("rev-parse", "HEAD").strip() + + def _git(self, *args): + out = subprocess.run( + ["git"] + list(args), cwd=str(self.repo), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, + ) + return out.stdout + + def _write(self, relative, text): + path = self.repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text + chr(10), encoding="utf-8") + + def _commit(self, relative, text, message): + self._write(relative, text) + self._git("add", "-A") + self._git("commit", "-qm", message) + return self._git("rev-parse", "HEAD").strip() + + def test_unchanged_surface_is_empty(self): + head = self._commit("README.md", "prose", "docs only") + self.assertEqual( + cvc.registration_surface_diff(self.repo, self.base, head), [] + ) + + def test_nested_cmakelists_is_on_the_surface(self): + head = self._commit("tests/CMakeLists.txt", + "add_test(NAME b COMMAND true)" + chr(10) + + "add_test(NAME c COMMAND true)", "new test") + self.assertEqual( + cvc.registration_surface_diff(self.repo, self.base, head), + ["tests/CMakeLists.txt"], + ) + + def test_cmake_module_is_on_the_surface(self): + head = self._commit("cmake/helpers.cmake", "# changed", "cmake module") + self.assertEqual( + cvc.registration_surface_diff(self.repo, self.base, head), + ["cmake/helpers.cmake"], + ) + + def test_test_body_is_off_the_surface(self): + head = self._commit("tests/test_body.cpp", "int main() { return 1; }", + "body only") + self.assertEqual( + cvc.registration_surface_diff(self.repo, self.base, head), [] + ) + + def test_unresolvable_shas_do_not_raise(self): + self.assertEqual( + cvc.registration_surface_diff(self.repo, "d" * 40, "e" * 40), [] + ) + + +class RealDocumentTest(unittest.TestCase): + """The repository's own VALIDATION.md must carry a parsable block.""" + + def test_repo_document_has_currency_block(self): + doc = REPO_ROOT / "docs" / "VALIDATION.md" + if not doc.exists(): + self.skipTest("docs/VALIDATION.md not present") + block = cvc.parse_currency_block(doc.read_text(encoding="utf-8")) + for key in cvc.REQUIRED_KEYS: + self.assertTrue(block.get(key), "missing %s" % key) + if str(block["tests"]).strip() == cvc.PENDING_TOKEN: + self.assertTrue(block.get("pending"), + "a PENDING count must record why it is pending") + else: + cvc.parse_tests_field(str(block["tests"])) + + def test_live_docs_do_not_duplicate_canonical_count(self): + self.assertEqual(cvc.hardcoded_test_counts(REPO_ROOT / "docs"), []) + + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/scripts/test_vla_plant.py b/tests/scripts/test_vla_plant.py index 7535c679..e073de36 100644 --- a/tests/scripts/test_vla_plant.py +++ b/tests/scripts/test_vla_plant.py @@ -192,7 +192,23 @@ def test_longitudinal_fx_accel(): # contract (real-time factor >= 5x). An unoptimised Debug build runs the same # code 3-6x slower, which says nothing about a performance regression, so the # caller states the budget that belongs to the build it produced. -BUDGET_S = float(os.environ.get("VDSIM_PERF_BUDGET_S", "1.0")) +def perf_budget_s(default_s): + """Wall-clock budget in seconds, taken from the environment if set. + + Mirrors ``vdsim::testing::perf_budget_s`` in tests/support/perf_budget.hpp: + same variable name, same rule. Unset, empty and malformed all mean "not + stated" and fall back to the Release contract, so a broken CI expression + cannot silently disable the assertion. + """ + raw = os.environ.get("VDSIM_PERF_BUDGET_S", "") + try: + parsed = float(raw) + except ValueError: + return default_s + return parsed if parsed > 0.0 else default_s + + +BUDGET_S = perf_budget_s(1.0) def test_speed_budget(): diff --git a/tests/support/perf_budget.hpp b/tests/support/perf_budget.hpp new file mode 100644 index 00000000..a2fa3613 --- /dev/null +++ b/tests/support/perf_budget.hpp @@ -0,0 +1,67 @@ +#pragma once + +/** + * @file perf_budget.hpp + * @brief Single source of truth for the wall-clock performance budgets used by + * the C++ test suite. + * + * A speed assertion measures the machine and the build configuration as much as + * it measures the code. An unoptimised Debug build runs the same plant 3-6x + * slower than Release, which says nothing about a performance regression, so the + * caller that produced the build states the budget that belongs to it. + * + * The override is read from the environment variable `VDSIM_PERF_BUDGET_S` + * (seconds). The same variable name and the same parsing rule are used by the + * Python suite (`tests/scripts/test_vla_plant.py`); keep the two in step. The + * default passed by each call site stays the Release contract, so a build that + * sets nothing keeps full regression-detection strength. + */ + +#include +#include + +namespace vdsim { +namespace testing { + +/// @brief Name of the environment variable that overrides a speed budget. +inline const char* perf_budget_env_name() { return "VDSIM_PERF_BUDGET_S"; } + +/** + * @brief Wall-clock budget in seconds, taken from the environment if set. + * @param default_s Budget to use when the variable is unset, empty or unparsable. + * This is the Release contract for the call site. + * @return The overriding value when it parses to a finite positive number, + * @p default_s otherwise. + * + * Unset, empty and malformed are all treated as "not stated" rather than as an + * error: a broken CI expression must not silently disable the assertion, and it + * must not turn a physics test into a configuration test either. + */ +inline double perf_budget_s(double default_s) { + const char* raw = std::getenv(perf_budget_env_name()); + if (raw == nullptr || raw[0] == '\0') { + return default_s; + } + try { + std::size_t consumed = 0; + const double parsed = std::stod(std::string(raw), &consumed); + if (consumed == 0 || !(parsed > 0.0)) { + return default_s; + } + return parsed; + } catch (...) { + return default_s; + } +} + +/** + * @brief Same budget expressed in milliseconds. + * @param default_ms Budget in milliseconds to use when the variable is unset. + * @return The effective budget in milliseconds. + */ +inline double perf_budget_ms(double default_ms) { + return perf_budget_s(default_ms / 1000.0) * 1000.0; +} + +} // namespace testing +} // namespace vdsim diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index b60ec91d..81013a37 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -20,10 +20,16 @@ add_executable(vdsim_unit_tests test_kinematic.cpp test_road_roughness.cpp test_lugre_tire_yaml.cpp + test_comms_templates.cpp + test_warn_throttle.cpp + test_contact_frame.cpp ) target_link_libraries(vdsim_unit_tests PRIVATE vdsim_carla_plugin) +# cosim/ is header-only for the wire protocol + TX templates (comms_templates.hpp). +target_include_directories(vdsim_unit_tests PRIVATE ${CMAKE_SOURCE_DIR}/cosim) + target_compile_definitions(vdsim_unit_tests PRIVATE VDSIM_SOURCE_DIR="${CMAKE_SOURCE_DIR}") diff --git a/tests/unit/test_comms_templates.cpp b/tests/unit/test_comms_templates.cpp new file mode 100644 index 00000000..e4cc829d --- /dev/null +++ b/tests/unit/test_comms_templates.cpp @@ -0,0 +1,811 @@ +// Unit tests for the text TX templates (cosim/comms_templates.hpp): +// template canonicalisation, NMEA 0183 checksum/framing, ENU->geodetic +// projection, GGA field layout and the JSON state encoder. +// +// Reference values marked "PROJ reference" were produced on the build host with +// pyproj 3.5 / PROJ 9 by an implementation independent of the header under +// test: EPSG:4979 -> EPSG:4978 for the datum, the textbook ENU rotation, then +// EPSG:4978 -> EPSG:4979 for the inverse. They were cross-checked against +// PROJ's own `+proj=topocentric` operation, which agreed to every printed +// digit. The generator lives in the review evidence, not in the build; it needs +// pyproj, which is not a VDSim dependency. +#include + +#include "comms_templates.hpp" + +#include +#include +#include +#include +#include + +using vdsim::cosim::GeodeticOrigin; +using vdsim::cosim::StateFields; + +namespace { + +// WGS84 radii of curvature at the equator. These were once treated as "exactly +// one degree" of offset; under the exact ENU->ECEF->geodetic inverse they are +// simply two convenient ~110 km offsets, and the tests below pin the exact +// latitude/longitude they actually map to. +constexpr double kMeridianDegAtEq = 110574.2727; // meridian scale at lat0 = 0 [m/deg] +constexpr double kPrimeVertDegAtEq = 111319.4908; // prime-vertical scale at lat0 = 0 [m/deg] + +const double kNaN = std::numeric_limits::quiet_NaN(); +const double kInf = std::numeric_limits::infinity(); + +// Split "$BODY*HH\r\n" into its body and its checksum digits. +std::pair split_sentence(const std::string& s) { + const auto star = s.rfind('*'); + EXPECT_NE(star, std::string::npos); + return {s.substr(1, star - 1), s.substr(star + 1, 2)}; +} + +// Comma-split an NMEA sentence body into fields. +std::vector fields_of(const std::string& body) { + std::vector out; + size_t i = 0; + while (true) { + const auto c = body.find(',', i); + if (c == std::string::npos) { out.push_back(body.substr(i)); break; } + out.push_back(body.substr(i, c - i)); + i = c + 1; + } + return out; +} + +// Parse "$...*HH\r\n" into fields, asserting the checksum digits are correct. +std::vector parse_checked_sentence(const std::string& sentence) { + EXPECT_EQ(sentence.substr(sentence.size() - 2), "\r\n"); + const auto [body, hex] = split_sentence(sentence.substr(0, sentence.size() - 2)); + char expect[4]; + std::snprintf(expect, sizeof(expect), "%02X", vdsim::cosim::nmea_checksum(body)); + EXPECT_EQ(hex, std::string(expect)) << sentence; + return fields_of(body); +} + +} // namespace + +TEST(CommsTemplates, CanonicalTxTemplate) { + EXPECT_EQ(vdsim::cosim::canonical_tx_template("vds1"), "vds1"); + EXPECT_EQ(vdsim::cosim::canonical_tx_template("vds1_state"), "vds1"); + EXPECT_EQ(vdsim::cosim::canonical_tx_template(""), "vds1"); + EXPECT_EQ(vdsim::cosim::canonical_tx_template("json"), "json"); + EXPECT_EQ(vdsim::cosim::canonical_tx_template("nmea_gga"), "nmea_gga"); + // Genuinely unknown templates must stay unknown so the server can skip them. + EXPECT_TRUE(vdsim::cosim::canonical_tx_template("nmea_rmc").empty()); + EXPECT_TRUE(vdsim::cosim::canonical_tx_template("vds1_cmd").empty()); + EXPECT_TRUE(vdsim::cosim::canonical_tx_template("JSON").empty()); +} + +// Hand-checkable reference from the NMEA 0183 GGA literature: the XOR of every +// character between '$' and '*' of this sentence is 0x47. +TEST(CommsTemplates, ChecksumMatchesReferenceSentence) { + const std::string body = + "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"; + EXPECT_EQ(vdsim::cosim::nmea_checksum(body), 0x47); + // XOR is self-inverse: folding the checksum back in must yield zero. + uint8_t sum = vdsim::cosim::nmea_checksum(body); + for (char c : body) sum ^= static_cast(c); + EXPECT_EQ(sum, 0); +} + +TEST(CommsTemplates, SentenceFramingAndChecksumDigits) { + const std::string s = vdsim::cosim::nmea_sentence({"GPGGA", "1", "", "2"}); + EXPECT_EQ(s.substr(0, 6), "$GPGGA"); + EXPECT_EQ(s.substr(s.size() - 2), "\r\n"); + const auto [body, hex] = split_sentence(s.substr(0, s.size() - 2)); + EXPECT_EQ(body, "GPGGA,1,,2"); + EXPECT_EQ(hex.size(), 2u); + for (char c : hex) EXPECT_TRUE((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) << hex; + char expect[4]; + std::snprintf(expect, sizeof(expect), "%02X", vdsim::cosim::nmea_checksum(body)); + EXPECT_EQ(hex, std::string(expect)); +} + +TEST(CommsTemplates, DegMinFormatting) { + // 48.1173 deg -> 48 deg 07.038 min (the reference sentence's latitude). + EXPECT_EQ(vdsim::cosim::nmea_deg_min(48.1173, 2), "4807.0380"); + // Sign is dropped: the hemisphere field carries it. + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-48.1173, 2), "4807.0380"); + // Longitude uses 3 degree digits, and minutes keep 2 padded integer digits. + EXPECT_EQ(vdsim::cosim::nmea_deg_min(11.5166666667, 3), "01131.0000"); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(0.0, 2), "0000.0000"); + // A minute value that rounds up to 60.0000 must carry into the degrees. + EXPECT_EQ(vdsim::cosim::nmea_deg_min(12.99999999, 2), "1300.0000"); +} + +// Guards the UB that produced "$GPGGA,010000.00,-2147483648 nan,S,..." — +// static_cast of NaN / 1e18 — and the embedded spaces that came with it. +TEST(CommsTemplates, DegMinRejectsNonFiniteAndOutOfRange) { + EXPECT_EQ(vdsim::cosim::nmea_deg_min(kNaN, 2), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(kInf, 3), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-kInf, 2), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(1.0e18, 3), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(180.0001, 3), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-180.0001, 3), ""); + // The extreme legal values are still formatted. + EXPECT_EQ(vdsim::cosim::nmea_deg_min(90.0, 2), "9000.0000"); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-180.0, 3), "18000.0000"); +} + +TEST(CommsTemplates, EnuToGeodeticZeroOffsetIsExactlyTheOrigin) { + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + const auto g = vdsim::cosim::enu_to_geodetic(0.0, 0.0, 0.0, o); + EXPECT_DOUBLE_EQ(g.lat_deg, o.lat_deg); + EXPECT_DOUBLE_EQ(g.lon_deg, o.lon_deg); + // Altitude goes through the ECEF round trip (h ~ 6.4e6 m of cancellation), + // so it returns to the datum to sub-nanometre, not to the last bit. + EXPECT_NEAR(g.alt_m, o.alt_m, 1.0e-8); +} + +TEST(CommsTemplates, EnuToGeodeticKnownOffsetAtEquator) { + const GeodeticOrigin o{0.0, 0.0, 0.0}; + const auto g = vdsim::cosim::enu_to_geodetic(kPrimeVertDegAtEq, + kMeridianDegAtEq, 12.5, o); + // PROJ reference for east/north ~110 km with up = 12.5 m. + EXPECT_NEAR(g.lat_deg, 0.999742207905550, 1e-9); + EXPECT_NEAR(g.lon_deg, 0.999896520255518, 1e-9); + // ENU is a flat tangent plane: 156 km out along it, the point sits ~1936 m + // above the ellipsoid even though `up` was only 12.5 m. + EXPECT_NEAR(g.alt_m, 1948.588722937, 1e-3); + // Sign convention: +east raises longitude, +north raises latitude. + const auto neg = vdsim::cosim::enu_to_geodetic(-kPrimeVertDegAtEq, + -kMeridianDegAtEq, 0.0, o); + EXPECT_NEAR(neg.lat_deg, -0.999744179416095, 1e-9); + EXPECT_NEAR(neg.lon_deg, -0.999898479474795, 1e-9); + EXPECT_NEAR(neg.alt_m, 1936.092528899, 1e-3); +} + +// D1 accuracy pin: away from the equator the old equirectangular form was off +// by 6.75 m at 10 km (lat 37.5665) and 15.2 m at 10 km (lat 60), because it +// froze the longitude scale at cos(lat0). The exact inverse must agree with the +// PROJ reference to well under a millimetre at both latitudes. +TEST(CommsTemplates, EnuToGeodeticMatchesProjReferenceAtTenKilometres) { + struct Case { + GeodeticOrigin o; + double east, north; + double lat_ref, lon_ref, alt_ref; + }; + const Case cases[] = { + {{37.5665, 126.9780, 38.0}, 10000.0, 0.0, + 37.566445740513110, 127.091189257618538, 45.829468708}, + {{37.5665, 126.9780, 38.0}, 0.0, 10000.0, + 37.656598293327995, 126.977999999999994, 45.862581023}, + {{60.0, 10.0, 0.0}, 10000.0, 0.0, + 59.999878434844135, 10.179210880059555, 7.819570524}, + {{60.0, 10.0, 0.0}, 0.0, 10000.0, + 60.089756019112222, 10.000000000000002, 7.832709746}, + }; + for (const Case& c : cases) { + const auto g = vdsim::cosim::enu_to_geodetic(c.east, c.north, 0.0, c.o); + // 1e-11 deg is ~1.1 um of ground distance. + EXPECT_NEAR(g.lat_deg, c.lat_ref, 1e-11) + << "lat0=" << c.o.lat_deg << " e=" << c.east << " n=" << c.north; + EXPECT_NEAR(g.lon_deg, c.lon_ref, 1e-11) + << "lat0=" << c.o.lat_deg << " e=" << c.east << " n=" << c.north; + EXPECT_NEAR(g.alt_m, c.alt_ref, 1e-6) + << "lat0=" << c.o.lat_deg << " e=" << c.east << " n=" << c.north; + } +} + +// The closed-form inverse builds lat/lon from atan2(), so the result is always +// range-reduced: crossing the antimeridian can no longer emit lon > 180. +TEST(CommsTemplates, EnuToGeodeticNormalizesAcrossTheAntimeridian) { + const GeodeticOrigin o{0.0, 179.9, 0.0}; + const auto g = vdsim::cosim::enu_to_geodetic(50000.0, 0.0, 0.0, o); + EXPECT_NEAR(g.lon_deg, -179.650851558491468, 1e-11); // PROJ reference + EXPECT_GE(g.lon_deg, -180.0); + EXPECT_LE(g.lon_deg, 180.0); + // ... and an out-of-range datum is reduced too, rather than propagated. + const GeodeticOrigin wrapped{0.0, 179.9 + 360.0, 0.0}; + const auto w = vdsim::cosim::enu_to_geodetic(50000.0, 0.0, 0.0, wrapped); + EXPECT_NEAR(w.lon_deg, g.lon_deg, 1e-9); + // Every latitude stays inside [-90, 90] even when driven over the pole. + const GeodeticOrigin polar{89.9, 0.0, 0.0}; + for (double n : {-5.0e6, -1.0e5, 0.0, 1.0e5, 5.0e6}) { + const auto p = vdsim::cosim::enu_to_geodetic(0.0, n, 0.0, polar); + EXPECT_GE(p.lat_deg, -90.0); + EXPECT_LE(p.lat_deg, 90.0); + EXPECT_GE(p.lon_deg, -180.0); + EXPECT_LE(p.lon_deg, 180.0); + } +} + +TEST(CommsTemplates, UtcFieldFormatsAndCarries) { + EXPECT_EQ(vdsim::cosim::nmea_utc_field(12 * 3600 + 35 * 60 + 19.0), "123519.00"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(0.0), "000000.00"); + // Seconds that round up to 60.00 must carry into the minute, the minute + // into the hour, and 23:59:59.997 must wrap the day - never "125960.00". + EXPECT_EQ(vdsim::cosim::nmea_utc_field(59.997), "000100.00"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(12 * 3600 + 34 * 60 + 59.997), "123500.00"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(12 * 3600 + 59 * 60 + 59.997), "130000.00"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(23 * 3600 + 59 * 60 + 59.997), "000000.00"); + // Out-of-range input wraps; non-finite input yields the NMEA "null" field. + EXPECT_EQ(vdsim::cosim::nmea_utc_field(86400.0 + 61.25), "000101.25"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(-1.0), "235959.00"); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(kNaN), ""); + EXPECT_EQ(vdsim::cosim::nmea_utc_field(kInf), ""); +} + +// Sweep the whole day at the 200 Hz tick period: no time field may ever print +// an illegal 60th second, 60th minute or 24th hour. +TEST(CommsTemplates, UtcFieldNeverPrintsAnIllegalTime) { + for (long i = 0; i < 86400L * 200L; i += 199) { // ~87k samples, prime stride + const double sod = static_cast(i) / 200.0; + const std::string t = vdsim::cosim::nmea_utc_field(sod); + ASSERT_EQ(t.size(), 9u) << sod << " -> " << t; + const int hh = std::stoi(t.substr(0, 2)); + const int mm = std::stoi(t.substr(2, 2)); + const double ss = std::stod(t.substr(4)); + ASSERT_LT(hh, 24) << sod << " -> " << t; + ASSERT_LT(mm, 60) << sod << " -> " << t; + ASSERT_LT(ss, 60.0) << sod << " -> " << t; + } + // The exact rounding boundary, which "%05.2f" of the raw seconds got wrong. + for (double eps : {0.0, 0.001, 0.002, 0.0025, 0.003, 0.004, 0.0049}) { + const std::string t = vdsim::cosim::nmea_utc_field(59.995 + eps); + EXPECT_EQ(t, "000100.00") << 59.995 + eps << " -> " << t; + } +} + +TEST(CommsTemplates, GgaZeroOffsetAtOrigin) { + StateFields s; // m_gnss_x/_y default to 0 -> at the datum + s.z = 0.0; + const GeodeticOrigin o{48.1173, 11.5166666667, 545.4}; + const auto f = parse_checked_sentence( + vdsim::cosim::encode_gga(s, o, 12 * 3600 + 35 * 60 + 19.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[0], "GPGGA"); + EXPECT_EQ(f[1], "123519.00"); + EXPECT_EQ(f[2], "4807.0380"); + EXPECT_EQ(f[3], "N"); + EXPECT_EQ(f[4], "01131.0000"); + EXPECT_EQ(f[5], "E"); + EXPECT_EQ(f[6], "1"); // fix quality + EXPECT_EQ(f[7], "12"); // satellites (synthetic) + EXPECT_EQ(f[8], "0.9"); // HDOP (synthetic) + EXPECT_EQ(f[9], "545.4"); // altitude + EXPECT_EQ(f[10], "M"); + EXPECT_EQ(f[11], "0.0"); // geoid separation (synthetic) + EXPECT_EQ(f[12], "M"); + EXPECT_EQ(f[13], ""); // DGPS age: not available + EXPECT_EQ(f[14], ""); // DGPS station id: not available +} + +TEST(CommsTemplates, GgaSouthernAndWesternHemisphere) { + // Origin in the southern/western hemisphere, vehicle at the datum: the + // sentence must report S and W with unsigned degree+minute fields. + StateFields s; + const GeodeticOrigin o{-33.8688, -70.6693, 570.0}; + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(s, o, 0.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[1], "000000.00"); + EXPECT_EQ(f[2], "3352.1280"); + EXPECT_EQ(f[3], "S"); + EXPECT_EQ(f[4], "07040.1580"); + EXPECT_EQ(f[5], "W"); +} + +TEST(CommsTemplates, GgaUsesMeasuredGnssAndEnuAxes) { + // x = ENU east, y = ENU north (core/include/vdsim/coordinate.hpp). + // Truth x/y is deliberately set to a different place from the measured GNSS + // to prove the sentence follows the receiver output, not the truth pose. + StateFields s; + s.x = -5000.0; s.y = -5000.0; + s.m_gnss_x = kPrimeVertDegAtEq; // ~1 deg east + s.m_gnss_y = kMeridianDegAtEq; // ~1 deg north + s.z = 0.0; + const GeodeticOrigin o{0.0, 0.0, 0.0}; + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(s, o, 0.0)); + ASSERT_EQ(f.size(), 15u); + // PROJ reference: lat 0.999744179416095, lon 0.999898479474795. + EXPECT_EQ(f[2], "0059.9847"); + EXPECT_EQ(f[3], "N"); + EXPECT_EQ(f[4], "00059.9939"); + EXPECT_EQ(f[5], "E"); +} + +// D5: an origin near the antimeridian plus an eastward offset must not emit an +// out-of-range "18020.9495,E" - it is 179 deg 39.05 min WEST. +TEST(CommsTemplates, GgaNormalizesLongitudeAcrossTheAntimeridian) { + StateFields s; + s.m_gnss_x = 50000.0; + const GeodeticOrigin o{0.0, 179.9, 0.0}; + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(s, o, 0.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[4], "17939.0511"); + EXPECT_EQ(f[5], "W"); + EXPECT_LT(std::stod(f[4]), 18100.0); // ddd part never exceeds 180 +} + +// D3: a diverged solver must produce a well-formed "no fix" sentence, never a +// checksum-valid sentence with "nan" and embedded spaces inside its fields. +TEST(CommsTemplates, GgaReportsNoFixForDegenerateState) { + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + struct Case { const char* name; double gx, gy, z; }; + const Case cases[] = { + {"nan position", kNaN, kNaN, kNaN}, + {"nan x only", kNaN, 10.0, 0.0}, + {"inf x", kInf, 0.0, 0.0}, + {"-inf y", 0.0, -kInf, 0.0}, + {"nan altitude", 10.0, 10.0, kNaN}, + {"1e18 offset", 1.0e18, 1.0e18, 1.0e18}, + {"1e300 offset", 1.0e300, 0.0, 0.0}, + }; + for (const Case& c : cases) { + StateFields s; + s.m_gnss_x = c.gx; s.m_gnss_y = c.gy; s.z = c.z; + const std::string sentence = vdsim::cosim::encode_gga(s, o, 3600.0); + const auto f = parse_checked_sentence(sentence); + ASSERT_EQ(f.size(), 15u) << c.name << ": " << sentence; + EXPECT_EQ(f[2], "") << c.name; // latitude: not available + EXPECT_EQ(f[3], "") << c.name; + EXPECT_EQ(f[4], "") << c.name; // longitude: not available + EXPECT_EQ(f[5], "") << c.name; + EXPECT_EQ(f[6], "0") << c.name; // fix quality 0 = invalid + EXPECT_EQ(f[7], "00") << c.name; + EXPECT_EQ(f[9], "") << c.name; // altitude: not available + EXPECT_EQ(f[10], "M") << c.name; + EXPECT_EQ(f[12], "M") << c.name; + // No field may contain whitespace, "nan" or "inf": a consumer splitting + // on commas must never see a token it cannot parse as a number. + for (const std::string& field : f) { + EXPECT_EQ(field.find(' '), std::string::npos) << c.name << ": " << sentence; + EXPECT_EQ(field.find("nan"), std::string::npos) << c.name << ": " << sentence; + EXPECT_EQ(field.find("inf"), std::string::npos) << c.name << ": " << sentence; + } + } + // A non-finite datum is rejected the same way. + StateFields ok; + const GeodeticOrigin bad{kNaN, 126.978, 38.0}; + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(ok, bad, 0.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[6], "0"); +} + +TEST(CommsTemplates, GgaValidatesOriginLatitudeRange) { + StateFields s; + + // The geographic poles are valid inclusive endpoints. + for (const double lat : {-90.0, 90.0}) { + const GeodeticOrigin origin{lat, 126.978, 38.0}; + const auto f = parse_checked_sentence( + vdsim::cosim::encode_gga(s, origin, 0.0)); + ASSERT_EQ(f.size(), 15u) << lat; + EXPECT_EQ(f[2], "9000.0000") << lat; + EXPECT_EQ(f[3], lat < 0.0 ? "S" : "N") << lat; + EXPECT_EQ(f[6], "1") << lat; + } + + // Any latitude outside [-90, 90] is not a geodetic datum. It must take + // the same well-formed no-fix path as a non-finite origin rather than be + // folded through sin/cos into a plausible-looking coordinate. + constexpr double kEpsilon = 1.0e-9; + for (const double lat : {-100.0, -90.0 - kEpsilon, + 90.0 + kEpsilon, 100.0}) { + const GeodeticOrigin origin{lat, 126.978, 38.0}; + const auto f = parse_checked_sentence( + vdsim::cosim::encode_gga(s, origin, 0.0)); + ASSERT_EQ(f.size(), 15u) << lat; + EXPECT_EQ(f[2], "") << lat; + EXPECT_EQ(f[3], "") << lat; + EXPECT_EQ(f[4], "") << lat; + EXPECT_EQ(f[5], "") << lat; + EXPECT_EQ(f[6], "0") << lat; + EXPECT_EQ(f[7], "00") << lat; + EXPECT_EQ(f[9], "") << lat; + } +} + +// N1 regression, layer 1: the ECEF->geodetic inverse is only single-valued +// outside the ellipsoid's evolute. Near/below the geocentre the Newton step's +// atan2() x-argument flips sign and returns a second/third-quadrant angle - +// a "latitude" of |lat| > 90 deg. That must be reported as failure, not +// returned as an angle. +TEST(CommsTemplates, EnuToGeodeticFailsInsideTheEarthInsteadOfReturningABadAngle) { + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + // The exact reproduction: a pure vertical divergence 6350 km down, which is + // still inside the kMaxEnuOffsetM = 1e8 m coarse guard and used to yield + // lat_deg = -163.551823. + const auto bad = vdsim::cosim::enu_to_geodetic(0.0, 0.0, -6.35e6, o); + EXPECT_FALSE(bad.ok); + EXPECT_TRUE(std::isnan(bad.lat_deg)); + EXPECT_TRUE(std::isnan(bad.lon_deg)); + EXPECT_TRUE(std::isnan(bad.alt_m)); + // The whole "inside the Earth" band, at 1 m steps: every result is either a + // clean failure or a latitude that actually exists. + for (double z = -6.5e6; z <= -6.2e6; z += 1.0) { + const auto g = vdsim::cosim::enu_to_geodetic(0.0, 0.0, z, o); + if (!g.ok) continue; + ASSERT_LE(std::fabs(g.lat_deg), 90.0) << "z=" << z; + ASSERT_LE(std::fabs(g.lon_deg), 180.0) << "z=" << z; + } + // Non-finite inputs and a non-finite datum fail rather than propagating. + EXPECT_FALSE(vdsim::cosim::enu_to_geodetic(kNaN, 0.0, 0.0, o).ok); + EXPECT_FALSE(vdsim::cosim::enu_to_geodetic(0.0, kInf, 0.0, o).ok); + EXPECT_FALSE(vdsim::cosim::enu_to_geodetic(0.0, 0.0, 0.0, + GeodeticOrigin{kNaN, 0.0, 0.0}).ok); + // A default-constructed result is "no fix", not a fix at Null Island: the + // documented "ok == false implies NaN angles" invariant holds for the + // defaults too, so a caller that forgets to test ok gets NaN, not (0, 0, 0). + const vdsim::cosim::Geodetic none; + EXPECT_FALSE(none.ok); + EXPECT_TRUE(std::isnan(none.lat_deg)); + EXPECT_TRUE(std::isnan(none.lon_deg)); + EXPECT_TRUE(std::isnan(none.alt_m)); + // Ordinary vehicle states keep reporting success. + for (double z : {-1000.0, -10.0, 0.0, 10.0, 1000.0, 1.0e4}) { + const auto g = vdsim::cosim::enu_to_geodetic(1234.0, -5678.0, z, o); + EXPECT_TRUE(g.ok) << "z=" << z; + } +} + +// N1 regression, layer 2: the NMEA angle field bound is per-field. Checking +// everything against 180 let a latitude of 163.55 deg through, which also +// widened the field to ten characters where GGA specifies nine (ddmm.mmmm). +TEST(CommsTemplates, DegMinUsesThePerFieldRangeBound) { + EXPECT_DOUBLE_EQ(vdsim::cosim::nmea_deg_max(2), 90.0); + EXPECT_DOUBLE_EQ(vdsim::cosim::nmea_deg_max(3), 180.0); + // The function is total, and only >= 3 digits widens the bound: every other + // digit count (including a nonsensical one) gets the tighter 90 deg. + for (int d : {-1, 0, 1, 2}) EXPECT_DOUBLE_EQ(vdsim::cosim::nmea_deg_max(d), 90.0) << d; + for (int d : {3, 4, 9}) EXPECT_DOUBLE_EQ(vdsim::cosim::nmea_deg_max(d), 180.0) << d; + // The reproduced value: rejected as a latitude, legal as a longitude. + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-163.551823, 2), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-163.551823, 3), "16333.1094"); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(90.0001, 2), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(-90.0001, 2), ""); + EXPECT_EQ(vdsim::cosim::nmea_deg_min(90.0, 2), "9000.0000"); // inclusive + // A latitude field is always ddmm.mmmm, a longitude field dddmm.mmmm. + for (double d = -90.0; d <= 90.0; d += 0.37) + ASSERT_EQ(vdsim::cosim::nmea_deg_min(d, 2).size(), 9u) << d; + for (double d = -180.0; d <= 180.0; d += 0.73) + ASSERT_EQ(vdsim::cosim::nmea_deg_min(d, 3).size(), 10u) << d; +} + +// N1 regression, layer 3: a state the projection cannot invert must take the +// same D3 "no fix" path as NaN, never a checksum-valid quality-1 sentence with +// an impossible latitude. +TEST(CommsTemplates, GgaNeverEmitsAQualityOneFixWithAnOutOfRangeField) { + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + // Asserts a sentence is either a well-formed fix or the "no fix" form. + auto check = [](const std::vector& f, double z) { + ASSERT_EQ(f.size(), 15u) << "z=" << z; + if (f[6] != "1") { // "no fix": D3's degradation + EXPECT_EQ(f[2], "") << "z=" << z; + EXPECT_EQ(f[3], "") << "z=" << z; + EXPECT_EQ(f[4], "") << "z=" << z; + EXPECT_EQ(f[5], "") << "z=" << z; + EXPECT_EQ(f[6], "0") << "z=" << z; + EXPECT_EQ(f[7], "00") << "z=" << z; + EXPECT_EQ(f[8], "99.9") << "z=" << z; + EXPECT_EQ(f[9], "") << "z=" << z; + return; + } + ASSERT_EQ(f[2].size(), 9u) << "z=" << z << " lat field " << f[2]; + ASSERT_EQ(f[4].size(), 10u) << "z=" << z << " lon field " << f[4]; + EXPECT_TRUE(f[3] == "N" || f[3] == "S") << "z=" << z; + EXPECT_TRUE(f[5] == "E" || f[5] == "W") << "z=" << z; + EXPECT_LE(std::stod(f[2]), 9000.0) << "z=" << z; // |lat| <= 90 deg + EXPECT_LE(std::stod(f[4]), 18000.0) << "z=" << z; // |lon| <= 180 deg + EXPECT_LT(std::stod(f[2].substr(2)), 60.0) << "z=" << z; + EXPECT_LT(std::stod(f[4].substr(3)), 60.0) << "z=" << z; + }; + // The exact reproduction, which emitted + // "$GPGGA,010000.00,16333.1094,S,12658.6800,E,1,12,0.9,-6402744.3,M,0.0,M,,*5D". + StateFields s; + s.z = -6.35e6; + const std::string sentence = vdsim::cosim::encode_gga(s, o, 3600.0); + const auto f = parse_checked_sentence(sentence); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[6], "0") << sentence; + EXPECT_EQ(f[2], "") << sentence; + check(f, s.z); + // The measured reachable bands, at 1 m steps, plus the surrounding region. + const double bands[][3] = {{-6361743.0, -6360052.0, 1.0}, + {-6356268.0, -6337299.0, 1.0}, + {-6334874.0, -6334540.0, 1.0}, + {-6500000.0, -6200000.0, 11.0}}; // surrounding region + for (const auto& b : bands) { + for (double z = b[0]; z <= b[1]; z += b[2]) { + StateFields t; + t.z = z; + check(parse_checked_sentence(vdsim::cosim::encode_gga(t, o, 3600.0)), z); + } + } + // ... and a coarse pass over the whole |z| range the coarse guard accepts, + // with horizontal offsets, so no other band can hide. + for (double h : {0.0, 1.0e3, 1.0e5, 1.0e7}) { + for (double z = -1.0e8; z <= 1.0e8; z += 5.0e4) { + StateFields t; + t.m_gnss_x = h; t.m_gnss_y = -h; t.z = z; + check(parse_checked_sentence(vdsim::cosim::encode_gga(t, o, 3600.0)), z); + } + } +} + +// N1 regression, layer 4: the evolute test is geometric, not axis-only. +// +// The evolute guard used to live inside the "on the spin axis" branch, so it +// only ever fired for p == 0. Off the axis the ECEF round-trip check was the +// sole gate — and it cannot do this job, because inside the evolute all four +// candidate latitudes reproduce the input ECEF point exactly. 2.3% of the +// astroid's interior therefore came back as a fix. +TEST(CommsTemplates, InsideEvoluteIsTheAstroidNotJustTheSpinAxis) { + using vdsim::cosim::inside_evolute; + const double ep = vdsim::cosim::kEvolutePMax; // 42697.7 m + const double ez = vdsim::cosim::kEvoluteZMax; // 42841.3 m + EXPECT_NEAR(ep, 42697.67, 0.01); + EXPECT_NEAR(ez, 42841.31, 0.01); + // The geocentre and both cusps are in; the boundary itself counts as in, + // because that is where two of the four roots merge. + EXPECT_TRUE(inside_evolute(0.0, 0.0)); + EXPECT_TRUE(inside_evolute(ep, 0.0)); + EXPECT_TRUE(inside_evolute(0.0, ez)); + EXPECT_TRUE(inside_evolute(0.0, -ez)); + // Just outside either cusp is out. The astroid is concave, so a point with + // both coordinates at half a cusp is already outside it (0.63 + 0.63 > 1). + EXPECT_FALSE(inside_evolute(ep * 1.000001, 0.0)); + EXPECT_FALSE(inside_evolute(0.0, ez * 1.000001)); + EXPECT_FALSE(inside_evolute(ep * 0.5, ez * 0.5)); + EXPECT_TRUE(inside_evolute(ep * 0.1, ez * 0.1)); + // Anywhere a vehicle can be is far outside, and non-finite reads as outside + // (enu_to_geodetic() screens those before calling). + EXPECT_FALSE(inside_evolute(6378137.0, 0.0)); + EXPECT_FALSE(inside_evolute(5.0e6, 3.0e6)); + EXPECT_FALSE(inside_evolute(kNaN, 0.0)); + EXPECT_FALSE(inside_evolute(kInf, kInf)); + // The astroid is a surface of revolution: only |z| matters, not its sign. + for (double t = 0.0; t <= 1.0; t += 0.05) + EXPECT_EQ(inside_evolute(ep * t, ez * t), inside_evolute(ep * t, -ez * t)) << t; +} + +// N1 regression, layer 4 (continued): the exact off-axis reproduction, and a +// sweep of the region it came from. +// +// Placing an arbitrary ECEF point (x, y, z) as an ENU offset about the (0,0,0) +// datum is exact and needs no projection: there N0 = a, the datum ECEF is +// (a, 0, 0) and the ENU rotation is a permutation, so +// east = y, north = z, up = x - a. +TEST(CommsTemplates, GgaRejectsOffAxisPointsInsideTheEvolute) { + constexpr double kA = 6378137.0; + const GeodeticOrigin o{0.0, 0.0, 0.0}; + auto state_at_ecef = [](double x, double y, double z) { + StateFields s; + s.m_gnss_x = y; // east + s.m_gnss_y = z; // north + s.z = x - kA; // up + return s; + }; + // The reproduction: ECEF (-2045.2, -1595.7, 23172.3), 23.3 km from the + // geocentre and strictly inside the astroid, with four distinct solutions. + // It used to return ok = 1 at lat 87.748546 / alt -6333529.1 m and encode + // "$GPGGA,010000.00,8744.9127,N,14202.2822,W,1,12,0.9,-6333529.1,M,0.0,M,,*6F" + // — a checksum-valid quality-1, 12-satellite, HDOP-0.9 fix at the Earth's + // core, which kMaxGgaAltitudeM = 1e7 does not filter. + StateFields s; + s.m_gnss_x = -1595.7; + s.m_gnss_y = 23172.3; + s.z = -6380182.2; + EXPECT_TRUE(vdsim::cosim::inside_evolute(std::hypot(-2045.2, -1595.7), 23172.3)); + const auto g = vdsim::cosim::enu_to_geodetic(s.m_gnss_x, s.m_gnss_y, s.z, o); + EXPECT_FALSE(g.ok); + EXPECT_TRUE(std::isnan(g.lat_deg)); + EXPECT_TRUE(std::isnan(g.lon_deg)); + EXPECT_TRUE(std::isnan(g.alt_m)); + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(s, o, 3600.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[6], "0"); // no fix, not quality 1 + EXPECT_EQ(f[2], ""); + EXPECT_EQ(f[4], ""); + EXPECT_EQ(f[9], ""); + // The whole interior, at two longitudes so the off-meridian path is covered + // as well: not one sentence may claim a fix. + const double ep = vdsim::cosim::kEvolutePMax; + const double ez = vdsim::cosim::kEvoluteZMax; + int inside = 0; + for (int ip = 0; ip <= 120; ++ip) { + for (int iz = -120; iz <= 120; ++iz) { + const double p = ep * (ip / 120.0) * 1.02; + const double z = ez * (iz / 120.0) * 1.02; + if (!vdsim::cosim::inside_evolute(p, z)) continue; + ++inside; + for (double lonr : {0.0, 1.234}) { + const StateFields t = state_at_ecef(p * std::cos(lonr), + p * std::sin(lonr), z); + ASSERT_FALSE(vdsim::cosim::enu_to_geodetic(t.m_gnss_x, t.m_gnss_y, + t.z, o).ok) + << "p=" << p << " z=" << z; + const auto ff = parse_checked_sentence( + vdsim::cosim::encode_gga(t, o, 3600.0)); + ASSERT_EQ(ff.size(), 15u); + ASSERT_EQ(ff[6], "0") << "p=" << p << " z=" << z; + } + } + } + EXPECT_GT(inside, 5000); // the sweep really did cover the region +} + +// The other half of layer 4, and the more important one: the geometric test +// must not cost a single legitimate fix. The evolute is 6335 km below the +// surface, so nothing a scene can produce is near it. +TEST(CommsTemplates, EvoluteGuardRejectsNoReachableVehicleState) { + int cases = 0; + for (double lat0 : {0.0, 37.5665, 60.0, 89.9, 90.0, -90.0, -33.8688}) { + for (double lon0 : {0.0, 126.978, 180.0, -180.0, -70.6693}) { + for (double alt0 : {0.0, 38.0, 8848.0}) { + const GeodeticOrigin o{lat0, lon0, alt0}; + for (double e = -200000.0; e <= 200000.0; e += 50000.0) { + for (double n = -200000.0; n <= 200000.0; n += 50000.0) { + for (double up = -2000.0; up <= 20000.0; up += 2000.0) { + ++cases; + StateFields s; + s.m_gnss_x = e; s.m_gnss_y = n; s.z = up; + ASSERT_TRUE(vdsim::cosim::enu_to_geodetic(e, n, up, o).ok) + << lat0 << "/" << lon0 << "/" << alt0 + << " e=" << e << " n=" << n << " up=" << up; + const auto f = parse_checked_sentence( + vdsim::cosim::encode_gga(s, o, 3600.0)); + ASSERT_EQ(f.size(), 15u); + ASSERT_EQ(f[6], "1") + << lat0 << "/" << lon0 << "/" << alt0 + << " e=" << e << " n=" << n << " up=" << up; + } + } + } + } + } + } + EXPECT_GT(cases, 30000); +} + +// An altitude too large to format must blank the field, not overflow it, while +// the horizontal fix (which is still meaningful) is kept. +TEST(CommsTemplates, GgaBlanksAnUnprintableAltitude) { + StateFields s; + s.m_gnss_x = 100.0; s.m_gnss_y = 100.0; + s.z = 5.0e7; // above kMaxGgaAltitudeM, still << kMaxEnuOffsetM + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + const auto f = parse_checked_sentence(vdsim::cosim::encode_gga(s, o, 0.0)); + ASSERT_EQ(f.size(), 15u); + EXPECT_EQ(f[6], "1"); // still a valid fix + EXPECT_NE(f[2], ""); + EXPECT_EQ(f[9], ""); // altitude: not available + EXPECT_EQ(f[10], "M"); +} + +// D4 at the encoder level: no encoded sentence may carry an illegal UTC field. +TEST(CommsTemplates, GgaNeverEncodesAnIllegalUtcField) { + StateFields s; + const GeodeticOrigin o{37.5665, 126.9780, 38.0}; + for (double base : {0.0, 45299.0, 46799.0, 86399.0}) { + for (double frac : {0.99, 0.995, 0.996, 0.997, 0.999, 0.9999}) { + const auto f = parse_checked_sentence( + vdsim::cosim::encode_gga(s, o, base + frac)); + ASSERT_EQ(f.size(), 15u); + ASSERT_EQ(f[1].size(), 9u) << base + frac << " -> " << f[1]; + EXPECT_LT(std::stoi(f[1].substr(0, 2)), 24) << f[1]; + EXPECT_LT(std::stoi(f[1].substr(2, 2)), 60) << f[1]; + EXPECT_LT(std::stod(f[1].substr(4)), 60.0) << f[1]; + } + } +} + +TEST(CommsTemplates, GgaFitsTheServerTxBuffer) { + StateFields s; + s.m_gnss_x = 1.0e5; s.m_gnss_y = -1.0e5; s.z = -1234.5; + uint8_t buf[vdsim::cosim::kTxBufBytes]; + const GeodeticOrigin o{-45.0, -70.0, 0.0}; + const int n = vdsim::cosim::encode_state_nmea_gga(buf, sizeof(buf), s, o, 86399.99); + ASSERT_GT(n, 0); + EXPECT_LT(n, static_cast(sizeof(buf))); + EXPECT_EQ(static_cast(buf[0]), '$'); + EXPECT_EQ(static_cast(buf[n - 2]), '\r'); + EXPECT_EQ(static_cast(buf[n - 1]), '\n'); + // Too small a buffer is reported, never silently truncated. + uint8_t tiny[8]; + EXPECT_EQ(vdsim::cosim::encode_state_nmea_gga(tiny, sizeof(tiny), s, o, 0.0), -1); +} + +TEST(CommsTemplates, UtcSecondsOfDayInRange) { + const double sod = vdsim::cosim::utc_seconds_of_day_now(); + EXPECT_GE(sod, 0.0); + EXPECT_LT(sod, 86400.0); +} + +TEST(CommsTemplates, JsonNumberFormatsOrEmitsNull) { + EXPECT_EQ(vdsim::cosim::json_number(1.25, 2), "1.25"); + EXPECT_EQ(vdsim::cosim::json_number(-0.5, 4), "-0.5000"); + EXPECT_EQ(vdsim::cosim::json_number(4500.0, 0), "4500"); + // RFC 8259 has no NaN/Infinity literal; "nan"/"inf" would break every parser. + EXPECT_EQ(vdsim::cosim::json_number(kNaN, 2), "null"); + EXPECT_EQ(vdsim::cosim::json_number(kInf, 2), "null"); + EXPECT_EQ(vdsim::cosim::json_number(-kInf, 4), "null"); + // Huge-but-finite values are printed in full, never truncated to a wrong + // number by the fixed scratch buffer. + const std::string big = vdsim::cosim::json_number(1.0e300, 2); + EXPECT_EQ(big.size(), 304u) << big.substr(0, 32); // 301 digits + "." + 2 + EXPECT_EQ(big.substr(0, 1), "1"); + EXPECT_EQ(big.substr(big.size() - 3), ".00"); +} + +TEST(CommsTemplates, JsonNumberArrayJoinsWithCommas) { + const double v[4] = {1.0, kNaN, -2.5, kInf}; + EXPECT_EQ(vdsim::cosim::json_number_array(v, 4, 1), "1.0,null,-2.5,null"); + EXPECT_EQ(vdsim::cosim::json_number_array(v, 1, 0), "1"); + EXPECT_EQ(vdsim::cosim::json_number_array(v, 0, 2), ""); +} + +TEST(CommsTemplates, JsonEncodesWellFormedObject) { + StateFields s; + s.vehicle_id = 7; s.timestamp = 1.25; + s.x = 45.6; s.y = -3.2; s.yaw = 0.04; + s.vx = 15.1; s.vy = 0.2; s.yaw_rate = 0.01; + s.ax = 0.3; s.ay = 1.2; + for (int i = 0; i < 4; ++i) { s.Fz[i] = 4500.0; s.slip_angle[i] = 0.01; s.slip_ratio[i] = 0.02; } + uint8_t buf[vdsim::cosim::kTxBufBytes]; + const int n = vdsim::cosim::encode_state_json(buf, sizeof(buf), s); + ASSERT_GT(n, 0); + const std::string j(reinterpret_cast(buf), n); + EXPECT_EQ(j.front(), '{'); + EXPECT_EQ(j.back(), '}'); + EXPECT_NE(j.find("\"id\":7"), std::string::npos) << j; + EXPECT_NE(j.find("\"t\":1.250"), std::string::npos) << j; + EXPECT_NE(j.find("\"Fz\":[4500,4500,4500,4500]"), std::string::npos) << j; + // Balanced braces/brackets and no embedded NUL: a truncated body would fail. + int braces = 0, brackets = 0; + for (char c : j) { + if (c == '{') ++braces; + else if (c == '}') --braces; + else if (c == '[') ++brackets; + else if (c == ']') --brackets; + EXPECT_NE(c, '\0'); + } + EXPECT_EQ(braces, 0); + EXPECT_EQ(brackets, 0); +} + +// D2: a diverged solver used to put "nan"/"inf" tokens on the wire in a ~190 B +// frame that sailed under the truncation guard, so json.loads and JSON.parse +// both rejected it. Every non-finite field must now be the literal `null`. +TEST(CommsTemplates, JsonEmitsNullForNonFiniteState) { + StateFields s; + s.vehicle_id = 3; s.timestamp = 1.5; + s.x = kNaN; s.y = -kInf; s.yaw = kInf; + s.vx = kNaN; s.vy = 1.0; s.yaw_rate = kNaN; + s.ax = 0.5; s.ay = kNaN; + for (int i = 0; i < 4; ++i) { s.Fz[i] = kNaN; s.slip_angle[i] = kInf; s.slip_ratio[i] = 0.02; } + uint8_t buf[vdsim::cosim::kTxBufBytes]; + const int n = vdsim::cosim::encode_state_json(buf, sizeof(buf), s); + ASSERT_GT(n, 0); + const std::string j(reinterpret_cast(buf), n); + EXPECT_EQ(j.find("nan"), std::string::npos) << j; + EXPECT_EQ(j.find("inf"), std::string::npos) << j; + EXPECT_EQ(j.find("NaN"), std::string::npos) << j; + EXPECT_NE(j.find("\"x\":null"), std::string::npos) << j; + EXPECT_NE(j.find("\"y\":null"), std::string::npos) << j; + EXPECT_NE(j.find("\"yaw\":null"), std::string::npos) << j; + EXPECT_NE(j.find("\"vy\":1.00"), std::string::npos) << j; // good fields survive + EXPECT_NE(j.find("\"ax\":0.50"), std::string::npos) << j; + EXPECT_NE(j.find("\"Fz\":[null,null,null,null]"), std::string::npos) << j; + EXPECT_NE(j.find("\"alpha\":[null,null,null,null]"), std::string::npos) << j; + EXPECT_NE(j.find("\"kappa\":[0.0200,0.0200,0.0200,0.0200]"), std::string::npos) << j; +} + +TEST(CommsTemplates, JsonReportsTruncationInsteadOfEmittingInvalidJson) { + StateFields s; + uint8_t small[32]; + EXPECT_EQ(vdsim::cosim::encode_state_json(small, sizeof(small), s), -1); + // Exactly-fitting buffer: the encoder needs one extra byte for snprintf's NUL. + uint8_t buf[vdsim::cosim::kTxBufBytes]; + const int n = vdsim::cosim::encode_state_json(buf, sizeof(buf), s); + ASSERT_GT(n, 0); + EXPECT_EQ(vdsim::cosim::encode_state_json(buf, static_cast(n), s), -1); + EXPECT_EQ(vdsim::cosim::encode_state_json(buf, static_cast(n) + 1, s), n); + // A finite value too large to fit overflows the buffer and is reported as a + // failure rather than emitted half-written. + StateFields huge; + huge.x = 1.0e300; huge.y = -1.0e300; + EXPECT_EQ(vdsim::cosim::encode_state_json(buf, sizeof(buf), huge), -1); +} diff --git a/tests/unit/test_contact_frame.cpp b/tests/unit/test_contact_frame.cpp new file mode 100644 index 00000000..018a7b40 --- /dev/null +++ b/tests/unit/test_contact_frame.cpp @@ -0,0 +1,112 @@ +#include "vdsim/contact_frame.hpp" + +#include + +#include +#include +#include +#include + +namespace { + +constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + +/** Return a road normal for positive/negative bank about body x. */ +vdsim::Vec3 bank_normal(double bank_rad) { + return vdsim::Vec3(0.0, -std::sin(bank_rad), std::cos(bank_rad)); +} + +} // namespace + +TEST(ContactFrame, FlatBasisPreservesCanonicalWheelFrame) { + const double steer = 0.17; + const vdsim::Vec3 forward(std::cos(steer), std::sin(steer), 0.0); + const auto frame = vdsim::make_contact_frame(vdsim::Vec3::UnitZ(), forward); + ASSERT_TRUE(frame.valid); + EXPECT_DOUBLE_EQ(frame.x_c.x(), forward.x()); + EXPECT_DOUBLE_EQ(frame.x_c.y(), forward.y()); + EXPECT_DOUBLE_EQ(frame.y_c.x(), -forward.y()); + EXPECT_DOUBLE_EQ(frame.y_c.y(), forward.x()); + EXPECT_EQ(frame.z_c, vdsim::Vec3::UnitZ()); +} + +TEST(ContactFrame, FiniteBankUsesExactProjectedRightHandedBasis) { + const double bank = 10.0 * kDegToRad; + const auto plus = vdsim::make_contact_frame( + bank_normal(bank), vdsim::Vec3::UnitX()); + const auto minus = vdsim::make_contact_frame( + bank_normal(-bank), vdsim::Vec3::UnitX()); + ASSERT_TRUE(plus.valid); + ASSERT_TRUE(minus.valid); + + EXPECT_NEAR(plus.x_c.norm(), 1.0, 1e-15); + EXPECT_NEAR(plus.y_c.norm(), 1.0, 1e-15); + EXPECT_NEAR(plus.z_c.norm(), 1.0, 1e-15); + EXPECT_NEAR(plus.x_c.dot(plus.z_c), 0.0, 1e-15); + EXPECT_NEAR((plus.z_c.cross(plus.x_c) - plus.y_c).norm(), 0.0, 1e-15); + + const vdsim::Vec3 velocity(15.0, 1.0, 0.0); + const auto vp = plus.resolve_velocity(velocity); + const auto vm = minus.resolve_velocity(velocity); + EXPECT_NEAR(vp.x(), 15.0, 1e-15); + EXPECT_NEAR(vp.y(), std::cos(bank), 1e-15); + EXPECT_NEAR(vm.y(), std::cos(bank), 1e-15); + EXPECT_NEAR(vp.z(), -vm.z(), 1e-15); + + const auto fp = plus.tangential_force(100.0, -200.0); + const auto fm = minus.tangential_force(100.0, -200.0); + EXPECT_NEAR(fp.x(), fm.x(), 1e-13); + EXPECT_NEAR(fp.y(), fm.y(), 1e-13); + EXPECT_NEAR(fp.z(), -fm.z(), 1e-13); + EXPECT_NEAR(fp.dot(plus.z_c), 0.0, 1e-13); + EXPECT_NEAR(fm.dot(minus.z_c), 0.0, 1e-13); +} + +TEST(ContactFrame, FzIsTheInjectedNormalDirectionComponent) { + const auto frame = vdsim::make_contact_frame( + bank_normal(10.0 * kDegToRad), vdsim::Vec3::UnitX()); + ASSERT_TRUE(frame.valid); + const double Fz = 3200.0; + const auto normal_force = frame.normal_force(Fz); + EXPECT_NEAR(normal_force.norm(), Fz, 1e-12); + EXPECT_NEAR(normal_force.dot(frame.z_c), Fz, 1e-12); + EXPECT_NEAR(normal_force.dot(frame.x_c), 0.0, 1e-12); + EXPECT_NEAR(normal_force.dot(frame.y_c), 0.0, 1e-12); +} + +TEST(ContactFrame, FullContactWrenchMatchesIndependentVectorTheory) { + const double bank = 10.0 * kDegToRad; + const auto frame = vdsim::make_contact_frame( + bank_normal(bank), vdsim::Vec3::UnitX()); + ASSERT_TRUE(frame.valid); + + constexpr double Fx = 120.0; + constexpr double Fy = -240.0; + constexpr double Fz = 3200.0; + const vdsim::Vec3 lever(1.4, 0.8, -0.5); + const double s = std::sin(bank); + const double c = std::cos(bank); + const vdsim::Vec3 expected_force( + Fx, Fy * c - Fz * s, Fy * s + Fz * c); + const vdsim::Vec3 expected_moment = lever.cross(expected_force); + + const auto actual_force = frame.contact_force(Fx, Fy, Fz); + const auto actual_moment = frame.contact_moment(lever, Fx, Fy, Fz); + EXPECT_NEAR((actual_force - expected_force).norm(), 0.0, 1e-12); + EXPECT_NEAR((actual_moment - expected_moment).norm(), 0.0, 2e-12); + + std::cout << std::setprecision(17) + << "[D1:wrench] force=" << actual_force.transpose() + << " moment=" << actual_moment.transpose() << '\n'; +} + +TEST(ContactFrame, DegenerateInputsFailClosedWithoutPlanarFallback) { + EXPECT_FALSE(vdsim::make_contact_frame( + vdsim::Vec3::Zero(), vdsim::Vec3::UnitX()).valid); + EXPECT_FALSE(vdsim::make_contact_frame( + vdsim::Vec3::UnitX(), vdsim::Vec3::UnitX()).valid); + vdsim::Vec3 nonfinite = vdsim::Vec3::UnitZ(); + nonfinite.x() = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(vdsim::make_contact_frame( + nonfinite, vdsim::Vec3::UnitX()).valid); +} diff --git a/tests/unit/test_warn_throttle.cpp b/tests/unit/test_warn_throttle.cpp new file mode 100644 index 00000000..dbccf832 --- /dev/null +++ b/tests/unit/test_warn_throttle.cpp @@ -0,0 +1,65 @@ +// Unit tests for the per-tick warning rate limiter (cosim/warn_throttle.hpp) +// used by the realtime server's comms TX loop. +#include + +#include "warn_throttle.hpp" + +using vdsim::cosim::WarnThrottle; +using vdsim::cosim::warn_due; + +TEST(WarnThrottle, FirstWarningAlwaysPasses) { + WarnThrottle w; + // Default state must fire whatever the clock reads, including t = 0 and a + // large steady_clock uptime. + EXPECT_TRUE(warn_due(0.0, w)); + WarnThrottle w2; + EXPECT_TRUE(warn_due(1.0e9, w2)); +} + +TEST(WarnThrottle, SuppressesInsideThePeriodAndFiresAfterIt) { + WarnThrottle w; + ASSERT_TRUE(warn_due(100.0, w)); + EXPECT_EQ(w.suppressed, 0u); + EXPECT_FALSE(warn_due(100.005, w)); + EXPECT_FALSE(warn_due(104.999, w)); + EXPECT_EQ(w.suppressed, 2u); + EXPECT_TRUE(warn_due(105.0, w)); + // The running total survives the print so the printed line can quote it. + EXPECT_EQ(w.suppressed, 2u); + EXPECT_FALSE(warn_due(105.1, w)); + EXPECT_EQ(w.suppressed, 3u); +} + +TEST(WarnThrottle, ThrottlesA200HzLoopToOneLinePerPeriod) { + // The regression this exists for: 3 s of a 200 Hz TX loop used to print 600 + // identical lines per channel. + WarnThrottle w; + int printed = 0; + for (int i = 0; i < 600; ++i) + if (warn_due(i / 200.0, w)) ++printed; + EXPECT_EQ(printed, 1); // t = 0 .. 2.995 s spans one period + EXPECT_EQ(w.suppressed, 599u); + for (int i = 600; i < 2000; ++i) + if (warn_due(i / 200.0, w)) ++printed; + EXPECT_EQ(printed, 2); // 10 s total -> a second line at t = 5 s +} + +TEST(WarnThrottle, HonoursAnExplicitPeriod) { + WarnThrottle w; + ASSERT_TRUE(warn_due(0.0, w, 0.5)); + EXPECT_FALSE(warn_due(0.4, w, 0.5)); + EXPECT_TRUE(warn_due(0.5, w, 0.5)); + // A zero period disables throttling entirely. + WarnThrottle none; + EXPECT_TRUE(warn_due(1.0, none, 0.0)); + EXPECT_TRUE(warn_due(1.0, none, 0.0)); + EXPECT_EQ(none.suppressed, 0u); +} + +TEST(WarnThrottle, EachCallSiteThrottlesIndependently) { + WarnThrottle a, b; + EXPECT_TRUE(warn_due(10.0, a)); + EXPECT_TRUE(warn_due(10.0, b)); + EXPECT_FALSE(warn_due(10.1, a)); + EXPECT_EQ(b.suppressed, 0u); +} diff --git a/tools/ac_fast_lane_importer.py b/tools/ac_fast_lane_importer.py new file mode 100644 index 00000000..95e7d5dc --- /dev/null +++ b/tools/ac_fast_lane_importer.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +"""Convert an Assetto Corsa v7 ``fast_lane.ai`` to the VDSim M1 bundle. + +The importer deliberately requires an explicit source-to-VDSim transform. AC +track origins and axis conventions are asset-dependent, so guessing an axis +swap would violate the map-import contract. This module is both the supported +Python API and the command-line implementation; it has no VDSim core imports. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import io +import json +import math +import os +import struct +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, Optional, Sequence + +import numpy as np +import yaml + +IMPORTER_VERSION = "1.0.0" +SCHEMA_VERSION = "1.0" +HEADER = struct.Struct("<4i") +BASE_POINT = struct.Struct("<4fi") +DETAIL_POINT = struct.Struct("<18f") +INT32 = struct.Struct(" len(data): + raise FastLaneImportError( + f"damaged fast_lane.ai: truncated {what} at byte {offset}" + ) + return layout.unpack_from(data, offset), end + + +def parse_fast_lane(path: os.PathLike | str) -> FastLaneSpline: + """Parse and validate a standard Assetto Corsa v7 AI spline. + + Supported layout is the v7 little-endian header, 20-byte base points, + detail-count marker, 72-byte detail points and the ACTools ``HasGrid`` + tail. A present grid is structurally consumed even though M1 does not use + it. Detail widths may be non-finite or absent; :func:`import_fast_lane` + then requires an explicit fallback rather than silently writing zero. + + :param path: input ``fast_lane.ai`` path. + :returns: immutable validated spline samples. + :raises FastLaneImportError: for unsupported or structurally damaged data. + """ + source = Path(path) + try: + data = source.read_bytes() + except OSError as exc: + raise FastLaneImportError(f"cannot read {source}: {exc}") from exc + + header, offset = _take(data, 0, HEADER, "header") + version, point_count, _reserved0, _reserved1 = header + if version != 7: + raise FastLaneImportError( + f"unsupported fast_lane.ai version {version}; supported: 7" + ) + if point_count < 2 or point_count > 10_000_000: + raise FastLaneImportError(f"invalid point count: {point_count}") + + bases = [] + previous_s = -math.inf + for index in range(point_count): + values, offset = _take(data, offset, BASE_POINT, f"base point {index}") + x, y, z, source_s, point_id = values + if not all(math.isfinite(v) for v in (x, y, z, source_s)): + raise FastLaneImportError(f"non-finite base point {index}") + if point_id != index: + raise FastLaneImportError( + f"damaged point sequence: id {point_id} at index {index}" + ) + if source_s < previous_s: + raise FastLaneImportError( + f"damaged point sequence: source distance decreases at {index}" + ) + previous_s = source_s + bases.append((x, y, z, source_s, point_id)) + + (detail_count,), offset = _take(data, offset, INT32, "detail count") + if detail_count not in (0, point_count): + raise FastLaneImportError( + f"detail count {detail_count} does not match point count {point_count}" + ) + + details = [] + for index in range(detail_count): + values, offset = _take(data, offset, DETAIL_POINT, f"detail point {index}") + details.append(values) + + (has_grid,), offset = _take(data, offset, INT32, "HasGrid flag") + if has_grid not in (0, 1): + raise FastLaneImportError(f"HasGrid flag must be 0 or 1, got {has_grid}") + grid_entries = 0 + if has_grid: + grid_entries, offset = _consume_grid(data, offset) + if offset != len(data): + raise FastLaneImportError( + f"unexpected trailing bytes after AI spline: {len(data) - offset}" + ) + + points = [] + for index, (x, y, z, source_s, point_id) in enumerate(bases): + left = right = None + if details: + raw_left, raw_right = details[index][5], details[index][6] + left = raw_left if math.isfinite(raw_left) and raw_left > 0.0 else None + right = raw_right if math.isfinite(raw_right) and raw_right > 0.0 else None + points.append(FastLanePoint( + position=(x, y, z), source_s_m=source_s, point_id=point_id, + left_width_m=left, right_width_m=right, + )) + return FastLaneSpline(version=version, points=tuple(points), + grid_entries=grid_entries) + + +def _checked_count(data: bytes, offset: int, what: str, + minimum_record_bytes: int = 4): + """Read a non-negative variable-array count bounded by remaining bytes.""" + (count,), offset = _take(data, offset, INT32, what) + if count < 0: + raise FastLaneImportError(f"negative {what}: {count}") + if count > 10_000_000: + raise FastLaneImportError(f"overflow {what}: {count}") + remaining = len(data) - offset + if minimum_record_bytes and count > remaining // minimum_record_bytes: + raise FastLaneImportError( + f"{what} {count} exceeds remaining payload ({remaining} bytes)" + ) + return count, offset + + +def _consume_grid(data: bytes, offset: int): + """Consume one ACTools ``AiSplineGrid`` payload and return its item count.""" + values, offset = _take(data, offset, GRID_META, "grid bounds/sampling") + max_extreme = values[:3] + min_extreme = values[3:6] + neighbors = values[6] + sampling_density = values[7] + if not all(math.isfinite(v) for v in (*max_extreme, *min_extreme, + sampling_density)): + raise FastLaneImportError("non-finite grid bounds or sampling density") + if any(maximum < minimum + for maximum, minimum in zip(max_extreme, min_extreme)): + raise FastLaneImportError("grid MaxExtreme is below MinExtreme") + if neighbors < 0: + raise FastLaneImportError( + f"negative grid NeighborsConsideredNumber: {neighbors}" + ) + if sampling_density <= 0.0: + raise FastLaneImportError( + f"grid SamplingDensity must be positive, got {sampling_density}" + ) + + item_count, offset = _checked_count(data, offset, "grid item count") + for item_index in range(item_count): + sub_count, offset = _checked_count( + data, offset, f"grid item {item_index} subitem count" + ) + for sub_index in range(sub_count): + value_count, offset = _checked_count( + data, offset, + f"grid item {item_index} subitem {sub_index} value count" + ) + byte_count = value_count * INT32.size + if byte_count > len(data) - offset: + raise FastLaneImportError( + f"grid item {item_index} subitem {sub_index} values " + "exceed remaining payload" + ) + offset += byte_count + return item_count, offset + + +def validate_transform(transform: Sequence[Sequence[float]]) -> np.ndarray: + """Validate a metre-preserving homogeneous source-to-ISO transform. + + Reflections are permitted because converting AC's handedness to ISO 8855 + can require one. Scale and shear are rejected by the orthonormality gate. + + :param transform: 4x4 nested numeric sequence. + :returns: validated ``float64`` NumPy array. + :raises FastLaneImportError: if the matrix is not finite and rigid. + """ + try: + matrix = np.asarray(transform, dtype=float) + except (TypeError, ValueError) as exc: + raise FastLaneImportError(f"invalid transform: {exc}") from exc + if matrix.shape != (4, 4) or not np.isfinite(matrix).all(): + raise FastLaneImportError("transform_source_to_vdsim must be finite 4x4") + if not np.allclose(matrix[3], [0.0, 0.0, 0.0, 1.0], atol=1e-12, rtol=0.0): + raise FastLaneImportError("transform last row must be [0, 0, 0, 1]") + rotation = matrix[:3, :3] + if not np.allclose(rotation.T @ rotation, np.eye(3), atol=1e-9, rtol=0.0): + raise FastLaneImportError("transform rotation must be orthonormal (no scale/shear)") + if not math.isclose(abs(float(np.linalg.det(rotation))), 1.0, + abs_tol=1e-9, rel_tol=0.0): + raise FastLaneImportError("transform rotation determinant must have magnitude 1") + return matrix + + +def load_transform(path: os.PathLike | str) -> np.ndarray: + """Load a 4x4 transform from JSON, accepting a direct array or manifest key.""" + transform_path = Path(path) + try: + payload = json.loads(transform_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise FastLaneImportError(f"cannot load transform JSON: {exc}") from exc + if isinstance(payload, dict): + if "transform_source_to_vdsim" not in payload: + raise FastLaneImportError( + "transform JSON object lacks transform_source_to_vdsim" + ) + payload = payload["transform_source_to_vdsim"] + return validate_transform(payload) + + +def _stable_float(value: float) -> str: + """Format SI values deterministically while normalizing negative zero.""" + if abs(value) < 0.5e-9: + value = 0.0 + return f"{value:.9f}" + + +def _bbox(points: np.ndarray) -> dict: + """Return deterministic XYZ bounding-box scalars.""" + return { + "min": [float(v) for v in points.min(axis=0)], + "max": [float(v) for v in points.max(axis=0)], + } + + +def _atomic_text(path: Path, text: str) -> None: + """Replace one UTF-8 output without exposing a partial file.""" + tmp = path.with_name(path.name + ".part") + with tmp.open("w", encoding="utf-8", newline="\n") as stream: + stream.write(text) + os.replace(tmp, path) + + +def import_fast_lane( + source_path: os.PathLike | str, + output_dir: os.PathLike | str, + *, + source_track_id: str, + transform_source_to_vdsim: Sequence[Sequence[float]], + closed_loop: bool, + fallback_half_width_m: Optional[float] = None, + generated_at: Optional[str] = None, +) -> ImportResult: + """Convert one v7 spline into deterministic M1 CSV, manifest and report. + + :param source_path: user-owned ``fast_lane.ai``; never modified. + :param output_dir: destination directory for the three M1 files. + :param source_track_id: stable user-provided AC track/layout identifier. + :param transform_source_to_vdsim: explicit 4x4 source-to-ISO transform. + :param closed_loop: explicit loop declaration; the binary has no reliable + loop flag, so the importer does not infer it. + :param fallback_half_width_m: explicit positive replacement for missing + per-side widths. ``None`` makes missing widths a hard error. + :param generated_at: UTC timestamp override for reproducible tests. + :returns: output paths and comparison evidence. + :raises FastLaneImportError: when any M1 contract cannot be met. + """ + source = Path(source_path) + if not source_track_id or not source_track_id.strip(): + raise FastLaneImportError("source_track_id must be non-empty") + if fallback_half_width_m is not None: + if not math.isfinite(fallback_half_width_m) or fallback_half_width_m <= 0.0: + raise FastLaneImportError("fallback_half_width_m must be finite and positive") + matrix = validate_transform(transform_source_to_vdsim) + before_stat = source.stat() + source_bytes = source.read_bytes() + spline = parse_fast_lane(source) + + raw_xyz = np.asarray([point.position for point in spline.points], dtype=float) + homogeneous = np.column_stack((raw_xyz, np.ones(len(raw_xyz)))) + output_xyz = (matrix @ homogeneous.T).T[:, :3] + segments = np.linalg.norm(np.diff(output_xyz, axis=0), axis=1) + if not np.isfinite(segments).all() or np.any(segments <= 1e-9): + raise FastLaneImportError("damaged point sequence: coincident consecutive samples") + s_m = np.concatenate(([0.0], np.cumsum(segments))) + closing_gap_m = float(np.linalg.norm(output_xyz[-1] - output_xyz[0])) + total_length_m = float(s_m[-1] + (closing_gap_m if closed_loop else 0.0)) + + warnings = [] + rows = [] + for index, (point, xyz) in enumerate(zip(spline.points, output_xyz)): + left, right = point.left_width_m, point.right_width_m + missing = [] + if left is None: + missing.append("left") + left = fallback_half_width_m + if right is None: + missing.append("right") + right = fallback_half_width_m + if missing: + if fallback_half_width_m is None: + raise FastLaneImportError( + f"point {index} missing {'/'.join(missing)} width; " + "pass an explicit fallback_half_width_m" + ) + warnings.append( + f"point {index}: missing {'/'.join(missing)} width; used explicit " + f"fallback {fallback_half_width_m:.9f} m" + ) + rows.append((index, float(s_m[index]), *map(float, xyz), + float(left), float(right))) + + csv_buffer = io.StringIO(newline="") + writer = csv.writer(csv_buffer, lineterminator="\n") + writer.writerow(CSV_COLUMNS) + for row in rows: + writer.writerow([row[0], *(_stable_float(v) for v in row[1:])]) + + if generated_at is None: + generated_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + manifest_data = { + "schema_version": SCHEMA_VERSION, + "source": "assetto_corsa", + "source_track_id": source_track_id.strip(), + "source_hash": hashlib.sha256(source_bytes).hexdigest(), + "coordinate_frame": { + "standard": "ISO 8855", + "axes": {"x": "forward", "y": "left", "z": "up"}, + }, + "units": {"length": "m", "angle": "rad"}, + "transform_source_to_vdsim": matrix.tolist(), + "generated_at": generated_at, + "included_layers": ["reference_path"], + "closed_loop": bool(closed_loop), + "source_format": {"name": "assetto_corsa_ai_spline", "version": 7}, + "importer": {"name": "vdsim-ac-fast-lane", "version": IMPORTER_VERSION}, + } + manifest_text = yaml.safe_dump( + manifest_data, sort_keys=False, allow_unicode=True, default_flow_style=False + ) + + source_length_m = float(spline.points[-1].source_s_m) + report_lines = [ + "# AC fast_lane.ai M1 import report", + "", + "## Comparison", + "", + "| Metric | Source | Output |", + "|---|---:|---:|", + f"| Point count | {len(spline.points)} | {len(rows)} |", + f"| Closed loop | declared: {str(bool(closed_loop)).lower()} | {str(bool(closed_loop)).lower()} |", + f"| Ordered-path length [m] | {source_length_m:.9f} | {float(s_m[-1]):.9f} |", + f"| Closing gap [m] | n/a | {closing_gap_m:.9f} |", + f"| Total length [m] | n/a | {total_length_m:.9f} |", + "", + "## Bounding boxes [m]", + "", + f"- Source XYZ: `{json.dumps(_bbox(raw_xyz), sort_keys=True)}`", + f"- VDSim XYZ: `{json.dumps(_bbox(output_xyz), sort_keys=True)}`", + "", + "## Warnings", + "", + ] + report_lines.extend(f"- {warning}" for warning in warnings) + if not warnings: + report_lines.append("- none") + report_text = "\n".join(report_lines) + "\n" + + output = Path(output_dir) + output.mkdir(parents=True, exist_ok=True) + reference_path = output / "reference_path.csv" + manifest = output / "manifest.yaml" + report = output / "report.md" + _atomic_text(reference_path, csv_buffer.getvalue()) + _atomic_text(manifest, manifest_text) + _atomic_text(report, report_text) + + after_stat = source.stat() + if (before_stat.st_mtime_ns, before_stat.st_size) != ( + after_stat.st_mtime_ns, after_stat.st_size + ): + raise FastLaneImportError("source fast_lane.ai changed during import") + return ImportResult( + reference_path=reference_path, manifest=manifest, report=report, + point_count=len(rows), closed_loop=bool(closed_loop), + total_length_m=total_length_m, warnings=tuple(warnings), + ) + + +def _parser() -> argparse.ArgumentParser: + """Build the public M1 command-line parser.""" + parser = argparse.ArgumentParser( + description="Convert AC fast_lane.ai v7 to VDSim M1 reference_path.csv" + ) + parser.add_argument("input", type=Path, help="path to user-owned fast_lane.ai") + parser.add_argument("--out-dir", type=Path, required=True, + help="destination for reference_path.csv, manifest.yaml, report.md") + parser.add_argument("--track-id", required=True, + help="stable Assetto Corsa track/layout identifier") + parser.add_argument("--transform-json", type=Path, required=True, + help="JSON 4x4 source-to-ISO transform (array or manifest key)") + parser.add_argument("--closed-loop", choices=("yes", "no"), required=True, + help="explicit loop declaration; never inferred") + parser.add_argument("--fallback-half-width-m", type=float, + help="explicit positive fallback for absent left/right widths") + return parser + + +def main(argv: Optional[Iterable[str]] = None) -> int: + """Run the M1 CLI, returning 0 on success and 2 on a contract error.""" + args = _parser().parse_args(argv) + try: + result = import_fast_lane( + args.input, args.out_dir, source_track_id=args.track_id, + transform_source_to_vdsim=load_transform(args.transform_json), + closed_loop=args.closed_loop == "yes", + fallback_half_width_m=args.fallback_half_width_m, + ) + except (FastLaneImportError, OSError) as exc: + print(f"ac_fast_lane_importer: {exc}", file=sys.stderr) + return 2 + print(f"wrote {result.reference_path}") + print(f"points={result.point_count} closed_loop={str(result.closed_loop).lower()} " + f"total_length_m={result.total_length_m:.9f}") + for warning in result.warnings: + print(f"warning: {warning}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bench_dynamics_levels.py b/tools/bench_dynamics_levels.py new file mode 100644 index 00000000..3c049235 --- /dev/null +++ b/tools/bench_dynamics_levels.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Benchmark VDSim dynamics ladder L0–L5: ms/step (ioniq5_awd, dt=5e-4, 500 steps). + +L0–L4: SimSession.tick() via pybind. L5 (Free3D stunt): C++ vdsim_bench_levels +(not wired into make_sim_session in pybind). +""" +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(REPO / "python"), str(REPO / "build" / "python")] + +import vdsim +from vdsim_plant import _load_tire_for_vehicle, resolve_vehicle_config + +DT = 5e-4 +N_STEPS = 500 +WARMUP = 50 +V0 = 20.0 + +PY_LEVELS = [ + ("L0", "L0"), + ("L1", "L1"), + ("L2", "L2"), + ("L3", "L3"), + ("L4", "L4"), +] + +BENCH_BIN = REPO / "build" / "bin" / "vdsim_bench_levels" + + +def _load_ioniq5(): + vp_path = resolve_vehicle_config("ioniq5_awd") + vp = vdsim.VehicleParams.from_yaml(str(vp_path)) + tp = _load_tire_for_vehicle(vp_path) + return vp, tp + + +def bench_py(level_code: str) -> float: + vp, tp = _load_ioniq5() + solver = vdsim.SolverParams() + sess = vdsim.make_sim_session( + vp, tp, level_code, nominal_dt=DT, solver=solver, mu=tp.mu_nominal + ) + sess.reset(vdsim.make_init_state(vp, tp, v=V0)) + cmd = vdsim.CmdL4() + cmd.steer_angle_wheel = 0.02 + + for _ in range(WARMUP): + sess.set_input(cmd) + sess.tick(DT) + + t0 = time.perf_counter() + for _ in range(N_STEPS): + sess.set_input(cmd) + sess.tick(DT) + return (time.perf_counter() - t0) / N_STEPS * 1e3 + + +def bench_cpp(level_code: str) -> float: + if not BENCH_BIN.is_file(): + raise FileNotFoundError( + f"{BENCH_BIN} missing — run: cmake --build build -j" + ) + proc = subprocess.run( + [ + str(BENCH_BIN), + f"--level={level_code}", + f"--dt={DT}", + f"--steps={N_STEPS}", + f"--warmup={WARMUP}", + f"--v0={V0}", + ], + check=True, + capture_output=True, + text=True, + ) + return float(proc.stdout.strip()) + + +def main() -> int: + results: dict[str, float] = {} + for label, code in PY_LEVELS: + ms = bench_py(code) + results[label] = round(ms, 4) + print(f"{label}: {ms:.4f} ms/step", flush=True) + + ms5 = bench_cpp("L5") + results["L5"] = round(ms5, 4) + print(f"L5: {ms5:.4f} ms/step", flush=True) + + print() + print("| level | ms/step |") + print("|-------|--------:|") + for lv in ("L0", "L1", "L2", "L3", "L4", "L5"): + print(f"| {lv} | {results[lv]:.4f} |") + + print(json.dumps(results, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/bench_trace_overhead.py b/tools/bench_trace_overhead.py new file mode 100755 index 00000000..ff1a4695 --- /dev/null +++ b/tools/bench_trace_overhead.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Measure the ``step()`` cost of the trace hook (performance gate, §5). + +The gate is on the OFF path: a plant that makes every user pay for a feature +they did not enable has transferred a regression to the customer. Recording is +opt-in and must cost <= 1 % of ``step()`` when off. + +The baseline is not a guess — it is the *previous committed revision* of +``python/vdsim_plant.py``, extracted with ``git show`` into a temporary module +and benchmarked in the same process, interleaved with the current one so +machine drift hits both conditions equally. + +Run:: + + python tools/bench_trace_overhead.py --steps 4000 --repeats 7 +""" +from __future__ import annotations + +import argparse +import importlib.util +import statistics +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(REPO / "python"), str(REPO / "build" / "python")] + + +def _load_baseline(rev: str, workdir: Path): + """Import ``python/vdsim_plant.py`` from ``rev`` as a separate module. + + :param rev: git revision holding the pre-hook plant. + :param workdir: scratch directory for the extracted source. + :returns: the imported module, or ``None`` when the revision has no such file. + """ + out = subprocess.run(["git", "-C", str(REPO), "show", + "%s:python/vdsim_plant.py" % rev], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if out.returncode != 0: + print("baseline unavailable at %s: %s" % (rev, out.stderr.decode().strip())) + return None + src = workdir / "vdsim_plant_baseline.py" + src.write_bytes(out.stdout) + spec = importlib.util.spec_from_file_location("vdsim_plant_baseline", str(src)) + mod = importlib.util.module_from_spec(spec) + sys.modules["vdsim_plant_baseline"] = mod + spec.loader.exec_module(mod) + return mod + + +def _run(plant_cls, steps, control_dt, trace_path=None, decimation=None): + plant = plant_cls(config="ioniq5_awd.yaml", base_mu=0.9, + control_dt=control_dt, substep_dt=5e-4) + plant.reset([0.0, 0.0, 0.0, 16.7, 0.0, 0.0]) + if trace_path is not None: + plant.enable_trace(trace_path, decimation=decimation) + u = [0.02, -2000.0] + t0 = time.perf_counter() + for _ in range(steps): + plant.step(u) + dt = time.perf_counter() - t0 + if trace_path is not None: + plant.finalize_trace() + return dt + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--steps", type=int, default=4000) + ap.add_argument("--repeats", type=int, default=7) + ap.add_argument("--control-dt", type=float, default=0.001) + ap.add_argument("--baseline-rev", default="HEAD", + help="git revision providing the pre-hook plant") + args = ap.parse_args() + + from vdsim_plant import VDSimPlant + + with tempfile.TemporaryDirectory() as td: + td = Path(td) + base_mod = _load_baseline(args.baseline_rev, td) + base_cls = getattr(base_mod, "VDSimPlant", None) if base_mod else None + + base, off, on1, ondec = [], [], [], [] + for r in range(args.repeats): + if base_cls is not None: + base.append(_run(base_cls, args.steps, args.control_dt)) + off.append(_run(VDSimPlant, args.steps, args.control_dt)) + on1.append(_run(VDSimPlant, args.steps, args.control_dt, + td / ("on1_%d.vdtrace" % r), 1)) + ondec.append(_run(VDSimPlant, args.steps, args.control_dt, + td / ("ond_%d.vdtrace" % r), None)) + + us = 1e6 / args.steps + + def show(label, xs, ref=None): + med = statistics.median(xs) + rel = "" if ref is None else " %+.2f%% vs baseline" % (100.0 * (med / ref - 1.0)) + print(" %-22s %8.3f us/step (min %8.3f)%s" % (label, med * us, min(xs) * us, rel)) + return med + + print("steps=%d repeats=%d control_dt=%g baseline=%s" + % (args.steps, args.repeats, args.control_dt, args.baseline_rev)) + ref = show("baseline (pre-hook)", base) if base else None + off_med = show("trace OFF", off, ref) + show("trace ON dec=1", on1, ref) + show("trace ON dec=auto", ondec, ref) + + if ref is not None: + pct = 100.0 * (off_med / ref - 1.0) + print("\nOFF-path overhead vs pre-hook baseline: %+.2f%% (gate: <= 1%%) -> %s" + % (pct, "PASS" if pct <= 1.0 else "FAIL")) + return 0 if pct <= 1.0 else 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/gui_v3_capture.py b/tools/gui_v3_capture.py new file mode 100644 index 00000000..6520d114 --- /dev/null +++ b/tools/gui_v3_capture.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Headless GUI v3 screenshot capture (Playwright + ephemeral gui/server.py).""" +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +V3 = REPO / "gui" / "v3" + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return int(s.getsockname()[1]) + + +def wait_http(port: int, timeout: float = 45.0) -> None: + url = f"http://127.0.0.1:{port}/api/state" + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + try: + with urllib.request.urlopen(url, timeout=2) as resp: + if resp.status == 200: + return + except (urllib.error.URLError, TimeoutError, OSError): + time.sleep(0.25) + raise RuntimeError(f"GUI server did not start on port {port}") + + +def py_env() -> dict: + env = os.environ.copy() + parts = [ + str(REPO / "build" / "python"), + str(REPO / "python"), + str(REPO / "cosim"), + str(REPO / "gui"), + ] + env["PYTHONPATH"] = ":".join(parts + ([env["PYTHONPATH"]] if env.get("PYTHONPATH") else [])) + return env + + +def main() -> int: + dist = V3 / "dist" / "index.html" + if not dist.is_file(): + subprocess.check_call(["npm", "run", "build"], cwd=V3) + + port = free_port() + env = py_env() + server = subprocess.Popen( + [sys.executable, str(REPO / "gui" / "server.py"), "--port", str(port), "--host", "127.0.0.1"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + try: + wait_http(port) + env["VDSIM_GUI_PORT"] = str(port) + r = subprocess.run( + ["npx", "playwright", "test", "e2e/capture-review.spec.ts", "--config=playwright.config.ts"], + cwd=V3, + env=env, + ) + out = REPO / "docs" / "evidence" / "review" / "gui-captures" + if out.is_dir(): + print("captures:", ", ".join(p.name for p in sorted(out.glob("*.png")))) + return r.returncode + finally: + server.terminate() + try: + server.wait(timeout=8) + except subprocess.TimeoutExpired: + server.kill() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gui_v3_eval_bundle.py b/tools/gui_v3_eval_bundle.py new file mode 100644 index 00000000..26ffcbd2 --- /dev/null +++ b/tools/gui_v3_eval_bundle.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Run headless GUI v3 evaluation bundle (no human).""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import date +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def run(cmd: list[str], cwd: Path | None = None, env: dict | None = None) -> dict: + p = subprocess.run(cmd, cwd=cwd or REPO, capture_output=True, text=True, env=env) + return { + "cmd": " ".join(cmd), + "exit_code": p.returncode, + "stdout_tail": "\n".join((p.stdout or "").splitlines()[-8:]), + "stderr_tail": "\n".join((p.stderr or "").splitlines()[-8:]), + } + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--skip-npm", action="store_true") + ap.add_argument("--skip-ctest", action="store_true") + ap.add_argument("--skip-e2e", action="store_true", help="skip Playwright headless browser") + args = ap.parse_args() + + day = date.today().isoformat() + out_dir = REPO / "docs" / "evidence" / "review" / day + out_dir.mkdir(parents=True, exist_ok=True) + + results: dict = {"date": day, "steps": []} + ok = True + + py_env = { + **dict(__import__("os").environ), + "PYTHONPATH": ":".join([ + str(REPO / "build" / "python"), + str(REPO / "python"), + str(REPO / "gui"), + str(REPO / "cosim"), + ]), + } + + if not args.skip_npm: + for label, cmd in ( + ("npm_check", ["npm", "run", "check"]), + ("npm_build", ["npm", "run", "build"]), + ): + r = run(cmd, cwd=REPO / "gui" / "v3") + r["step"] = label + results["steps"].append(r) + ok = ok and r["exit_code"] == 0 + + r = run([sys.executable, "tests/scripts/test_gui_v3_api_smoke.py"], env=py_env) + r["step"] = "gui_v3_api_smoke" + results["steps"].append(r) + ok = ok and r["exit_code"] == 0 + + if not getattr(args, "skip_e2e", False): + r = run([sys.executable, "tests/scripts/test_gui_v3_e2e.py"], env=py_env) + r["step"] = "gui_v3_e2e" + results["steps"].append(r) + ok = ok and r["exit_code"] == 0 + + r = run([sys.executable, "tools/gui_v3_parity_audit.py", "--out-dir", str(out_dir)]) + r["step"] = "parity_audit" + results["steps"].append(r) + parity_exit = r["exit_code"] + results["p0_gaps_blocking"] = parity_exit != 0 + + if not args.skip_ctest: + build = REPO / "build" + if (build / "CTestTestfile.cmake").is_file(): + for name, regex in ( + ("ctest_assembly", "assembly"), + ("ctest_catalog", "catalog"), + ("ctest_compare", "multi_vehicle_compare"), + ): + r = run(["ctest", "-R", regex, "--output-on-failure"], cwd=build) + r["step"] = name + results["steps"].append(r) + ok = ok and r["exit_code"] == 0 + else: + results["steps"].append({ + "step": "ctest_skipped", + "exit_code": 0, + "note": "build/ missing — run cmake", + }) + + (out_dir / "gui_v3_eval_bundle.json").write_text( + json.dumps(results, indent=2) + "\n", encoding="utf-8") + + lines = [ + f"# GUI v3 eval bundle — {day}", + "", + f"Overall automation: **{'PASS' if ok else 'FAIL'}**", + f"P0 parity gaps (static): **{'yes' if parity_exit else 'no'}**", + "", + "| Step | Exit |", + "|------|------|", + ] + for s in results["steps"]: + lines.append(f"| {s.get('step', '?')} | {s.get('exit_code', '?')} |") + lines.extend([ + "", + "Artifacts:", + f"- `{out_dir / 'gui_v3_parity_matrix.json'}`", + f"- `{out_dir / 'GUI_V3_PARITY_AUDIT.md'}`", + f"- `{out_dir / 'gui_v3_eval_bundle.json'}`", + "", + "Run: `python3 tools/gui_v3_eval_bundle.py`", + ]) + (out_dir / "GUI_V3_EVAL_BUNDLE.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + print(out_dir / "GUI_V3_EVAL_BUNDLE.md") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gui_v3_parity_audit.py b/tools/gui_v3_parity_audit.py new file mode 100644 index 00000000..c0cd2332 --- /dev/null +++ b/tools/gui_v3_parity_audit.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Static parity audit: GUI v3 sources vs GUI_V3_REQUIREMENTS IDs (no browser).""" +from __future__ import annotations + +import argparse +import json +import sys +from datetime import date +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +V3 = REPO / "gui" / "v3" / "src" + + +def src_text() -> str: + parts: list[str] = [] + for p in sorted(V3.rglob("*")): + if p.suffix in (".svelte", ".ts", ".js") and p.is_file(): + parts.append(p.read_text(encoding="utf-8", errors="replace")) + return "\n".join(parts) + + +def file_exists(rel: str) -> bool: + return (V3 / rel).is_file() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--out-dir", type=Path, default=None) + args = ap.parse_args() + + t = src_text() + dist_ok = (REPO / "gui" / "v3" / "dist" / "index.html").is_file() + + def pass_(note: str) -> dict: + return {"status": "PASS", "note": note} + + def partial(note: str) -> dict: + return {"status": "PARTIAL", "note": note} + + def gap(note: str) -> dict: + return {"status": "GAP", "note": note} + + checks: list[dict] = [ + {"id": "SH-01", "group": "shell", "prio": "P0", **pass_("hash routes scenario/run/build/analyze")}, + {"id": "SH-02", "group": "shell", "prio": "P0", + **(pass_("scenario save/load in ScenarioMode") if "saveScenarioDraft" in t else gap("no save"))}, + {"id": "SH-03", "group": "shell", "prio": "P0", + **(pass_("Play → run") if "playSimulation" in t and "mode = 'run'" in t else gap("no play→run"))}, + {"id": "SH-04", "group": "shell", "prio": "P0", + **(partial("kin warnings in Run status only") if "kinematics_warnings" in t else gap("no warning strip"))}, + {"id": "SH-05", "group": "shell", "prio": "P0", + **(pass_("mkdocs help links") if "HelpLink" in t and "docsHelp" in t else gap("mkdocs [?] help links not wired"))}, + {"id": "SH-06", "group": "shell", "prio": "P1", **gap("panel resize persist")}, + {"id": "CO-00", "group": "scenario", "prio": "P0", + **(pass_("template gallery") if "SCENARIO_TEMPLATES" in t and "applyScenarioTemplate" in t else gap("template gallery"))}, + {"id": "CO-01", "group": "scenario", "prio": "P0", + **(pass_("road μ/grade/bank") if "gradeDeg" in t and "s.road.mu" in t else gap("road params"))}, + {"id": "CO-02", "group": "scenario", "prio": "P1", **gap("OpenDRIVE xodr")}, + {"id": "CO-03", "group": "scenario", "prio": "P1", **gap("terrain obj")}, + {"id": "CO-04", "group": "scenario", "prio": "P0", + **(pass_("fleet table") if "fleet" in t and "FleetAgent" in t else gap("fleet"))}, + {"id": "CO-05", "group": "scenario", "prio": "P0", + **(pass_("map drag") if "MapCanvas" in t and "dragVid" in t else gap("map drag"))}, + {"id": "CO-06", "group": "scenario", "prio": "P0", + **(pass_("path preset + wp table") if "path_preset" in t and 'class="wp"' in t else gap("path"))}, + {"id": "CO-07", "group": "scenario", "prio": "P0", + **(pass_("path edit") if "pathEdit" in t else gap("waypoint edit"))}, + {"id": "CO-08", "group": "scenario", "prio": "P0", **pass_("static map path; Run viewport separate")}, + {"id": "CO-09", "group": "scenario", "prio": "P1", **gap("cosim attach UI")}, + {"id": "CO-14", "group": "scenario", "prio": "P1", + **(partial("scenario list from API") if "scenarios" in t else gap("preset chips"))}, + {"id": "CO-15", "group": "scenario", "prio": "P0", + **(pass_("build?vid=") if "buildHref" in t else gap("edit parts link"))}, + {"id": "CO-16", "group": "scenario", "prio": "P0", + **(pass_("save/load scenario") if "saveScenarioDraft" in t else gap("scene save"))}, + {"id": "BU-01", "group": "build", "prio": "P0", + **(pass_("blueprint presets") if "recommended" in t else gap("presets"))}, + {"id": "BU-02", "group": "build", "prio": "P0", + **(pass_("categories") if "categories" in t else gap("categories"))}, + {"id": "BU-03", "group": "build", "prio": "P0", + **(pass_("preview Δ + install") if "pinnedCandidate" in t else gap("card preview"))}, + {"id": "BU-04", "group": "build", "prio": "P0", + **(pass_("stats panel") if "mass_kg" in t else gap("stats"))}, + {"id": "BU-05", "group": "build", "prio": "P0", + **(pass_("build_complete surfaced") if "build_complete" in t else gap("build_complete"))}, + {"id": "BU-06", "group": "build", "prio": "P0", + **(pass_("L1–L5") if "FLEET_LEVELS" in t else gap("levels"))}, + {"id": "BU-07", "group": "build", "prio": "P1", + **(pass_("compat badges") if "slotCompatTone" in t else gap("compat"))}, + {"id": "BU-08", "group": "build", "prio": "P0", + **(pass_("blueprint confirm") if "Changing blueprint replaces" in t else gap("confirm"))}, + {"id": "BU-09", "group": "build", "prio": "P1", + **(pass_("export blueprint") if "exportBlueprintYaml" in t else gap("export"))}, + {"id": "BU-10", "group": "build", "prio": "P0", + **(pass_("linkage SVG") if "linkageSvg" in t else gap("schematic"))}, + {"id": "BU-11", "group": "build", "prio": "P0", **pass_("no Three.js in BuildMode")}, + {"id": "BU-13", "group": "build", "prio": "P2", + **(pass_("K&C in HardpointEditor") if "kcPlotSvg" in t else gap("K&C plots"))}, + {"id": "BU-14", "group": "build", "prio": "P1", + **(pass_("PartsLibrary") if file_exists("components/PartsLibrary.svelte") else gap("parts lib"))}, + {"id": "BU-HP", "group": "build", "prio": "P0", + **(pass_("HardpointEditor") if file_exists("components/HardpointEditor.svelte") else gap("L4 HP editor"))}, + {"id": "RN-00", "group": "run", "prio": "P0", **pass_("Run mode route")}, + {"id": "RN-01", "group": "run", "prio": "P0", + **(pass_("stop/reset") if "controlAction" in t else gap("transport"))}, + {"id": "RN-02", "group": "run", "prio": "P0", + **(pass_("autopilot/manual + manbar") if "createManualControl" in t and "setFleetDriver" in t else gap("autopilot/manual + manbar"))}, + {"id": "RN-03", "group": "run", "prio": "P0", + **(pass_("time scale") if "setTimeScale" in t else gap("time scale"))}, + {"id": "RN-04", "group": "run", "prio": "P0", + **(pass_("cam modes") if "CamMode" in t else gap("camera"))}, + {"id": "RN-05", "group": "run", "prio": "P0", + **(pass_("SSE viewport") if "RunViewport" in t else gap("stream"))}, + {"id": "RN-06", "group": "run", "prio": "P1", + **(pass_("wheel force triad") if "wheelForces" in t and "updateWheelForces" in t else gap("wheel force triad"))}, + {"id": "RN-07", "group": "run", "prio": "P0", + **(pass_("telemetry") if "pickVehicleState" in t else gap("telemetry"))}, + {"id": "AN-01", "group": "analyze", "prio": "P0", + **(pass_("fleet/catalog picks") if "AnalyzeMode" in t else gap("picks"))}, + {"id": "AN-02", "group": "analyze", "prio": "P0", + **(pass_("maneuvers") if "COMPARE_MANEUVERS" in t else gap("maneuvers"))}, + {"id": "AN-03", "group": "analyze", "prio": "P0", + **(pass_("yaw chart") if "yawRateOverlaySvg" in t else gap("chart"))}, + {"id": "AN-04", "group": "analyze", "prio": "P0", + **(pass_("delta table") if "rowsToCsv" in t else gap("table"))}, + {"id": "AN-05", "group": "analyze", "prio": "P1", + **(pass_("CSV export") if "downloadCsv" in t or "rowsToCsv" in t else gap("csv"))}, + {"id": "AN-06", "group": "analyze", "prio": "P0", **pass_("separate Analyze mode route")}, + {"id": "NF-BUILD", "group": "nonfunctional", "prio": "P0", + **(pass_("v3 dist present") if dist_ok else gap("npm run build required"))}, + ] + + counts = {"PASS": 0, "PARTIAL": 0, "GAP": 0} + p0_gap = 0 + for c in checks: + counts[c["status"]] += 1 + if c["prio"] == "P0" and c["status"] == "GAP": + p0_gap += 1 + + day = date.today().isoformat() + out_dir = args.out_dir or (REPO / "docs" / "evidence" / "review" / day) + out_dir.mkdir(parents=True, exist_ok=True) + + matrix = { + "date": day, + "tool": "gui_v3_parity_audit.py", + "counts": counts, + "p0_gaps": p0_gap, + "checks": checks, + } + (out_dir / "gui_v3_parity_matrix.json").write_text( + json.dumps(matrix, indent=2) + "\n", encoding="utf-8") + + lines = [ + f"# GUI v3 parity audit — {day}", + "", + f"Automated static audit (no browser). P0 gaps: **{p0_gap}**", + "", + f"| Status | Count |", + f"|--------|-------|", + f"| PASS | {counts['PASS']} |", + f"| PARTIAL | {counts['PARTIAL']} |", + f"| GAP | {counts['GAP']} |", + "", + "| ID | Prio | Status | Note |", + "|----|------|--------|------|", + ] + for c in checks: + lines.append(f"| {c['id']} | {c['prio']} | {c['status']} | {c['note']} |") + lines.extend([ + "", + "Regenerate: `python3 tools/gui_v3_parity_audit.py`", + "API smoke: `python3 tests/scripts/test_gui_v3_api_smoke.py`", + ]) + report = out_dir / "GUI_V3_PARITY_AUDIT.md" + report.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(report) + print(f"P0 gaps: {p0_gap} PASS/PARTIAL/GAP = {counts['PASS']}/{counts['PARTIAL']}/{counts['GAP']}") + return 1 if p0_gap else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/module_workshop.py b/tools/module_workshop.py index 03d8084e..ee33b82d 100644 --- a/tools/module_workshop.py +++ b/tools/module_workshop.py @@ -18,6 +18,7 @@ import argparse import json +import os import subprocess import sys from pathlib import Path @@ -28,19 +29,39 @@ KINDS = ("brake", "steering", "drivetrain", "suspension", "antirollbar") +def build_dir(repo: Path) -> Path: + """Return the CMake binary directory to compile user modules against. + + ``VDSIM_BUILD_DIR`` wins so that ctest can point the tool at whichever + directory the running configuration produced; the canonical `validation` + preset builds into ``build-validation``, not ``build``. Without the + variable the first existing candidate is used, which keeps the documented + ``cmake -B build`` workflow working unchanged. + """ + env = os.environ.get("VDSIM_BUILD_DIR", "").strip() + if env: + return Path(env) + for name in ("build", "build-validation"): + if (repo / name / "lib" / "libvdsim_core.a").is_file(): + return repo / name + return repo / "build" + + def _discover(repo: Path) -> dict: """Locate the include/lib/checker the build produced. Returns paths or an error.""" core_inc = repo / "core" / "include" - lib_dir = repo / "build" / "lib" - checker = repo / "build" / "bin" / "vdsim_module_check" - eigen = sorted((repo / "build" / "_deps").glob("eigen-src")) + build = build_dir(repo) + rel = build.name + lib_dir = build / "lib" + checker = build / "bin" / "vdsim_module_check" + eigen = sorted((build / "_deps").glob("eigen-src")) missing = [] if not (lib_dir / "libvdsim_core.a").is_file(): - missing.append("build/lib/libvdsim_core.a (build the project first)") + missing.append("%s/lib/libvdsim_core.a (build the project first)" % rel) if not checker.is_file(): - missing.append("build/bin/vdsim_module_check (build the project first)") + missing.append("%s/bin/vdsim_module_check (build the project first)" % rel) if not eigen: - missing.append("Eigen headers under build/_deps/eigen-src") + missing.append("Eigen headers under %s/_deps/eigen-src" % rel) return { "core_inc": core_inc, "lib_dir": lib_dir, diff --git a/tools/review_evidence_bundle.py b/tools/review_evidence_bundle.py index df6a4a3c..2d05e545 100644 --- a/tools/review_evidence_bundle.py +++ b/tools/review_evidence_bundle.py @@ -14,6 +14,7 @@ LANES = [ ("validation", ["ChronoKcParity", "IsoBaseline"]), + ("plant", ["VlaPlant", "vla_plant"]), ("core_session", ["PerWheel", "PerAxle", "SimSession", "DragCoast"]), ("catalog", ["catalog", "assembly", "blueprint", "multi_vehicle"]), ]