diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a7e64863..15881389 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,10 +43,11 @@ jobs: # bindings must be built or every one of them fails with # ModuleNotFoundError. pybind11 is pinned: it decides how the module is # compiled, so an unpinned bump is a silent toolchain change. + # gymnasium is pinned for ctest rl_env (vdsim_rl); a missing import fails. - name: Install Python dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow + python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow "gymnasium==0.29.1" - name: Report toolchain run: | @@ -121,7 +122,7 @@ jobs: - name: Install Python dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow + python3 -m pip install "pybind11==2.13.6" numpy pyyaml matplotlib pillow "gymnasium==0.29.1" - name: Pin CMake # CMakePresets.json is version 3, which needs CMake >= 3.21, so the diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 7522745c..c4b9e858 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -30,7 +30,7 @@ jobs: CIBW_TEST_COMMAND: >- python -c "import vdsim; print(vdsim.__file__)" && python -c "from vdsim_lab import Sim; Sim(vehicle='sedan', level='L2'); print('sedan ok')" && - python -c "from vdsim_lab import Sim; Sim(vehicle='ioniq5_awd', tire='ioniq5_pac2002', level='L2'); print('ioniq5 ok')" && + python -c "from vdsim_lab import Sim; Sim(vehicle='generic_sedan', tire='generic_pacejka', level='L2'); print('generic ok')" && python "{project}/tools/wheel_import_smoke.py" - uses: actions/upload-artifact@v4 with: diff --git a/configs/parts/tire/generic_pacejka.yaml b/configs/parts/tire/generic_pacejka.yaml new file mode 100644 index 00000000..16ca278e --- /dev/null +++ b/configs/parts/tire/generic_pacejka.yaml @@ -0,0 +1,42 @@ +# Generic Pacejka MF96 + LuGre tyre -- the values core/src/params.cpp compiles +# in as the TireParams() defaults, in the flat TireParams shape. +# +# Source: vdsim.TireParams().to_yaml(), 2026-09-22. Not measured data. +# configs/parts/tire/default_pacejka.yaml carries the same tyre in the catalog +# part schema (id/type/schema/body); vdsim_rl reads flat files only and refuses +# catalog parts by design, so the generic car needs this flat copy. +# tests/rl/test_rl_env.py (Q23-f1) fails if these drift from the C++ defaults. +B_long: 10.0 +C_long: 1.65 +D_long: 1.0 +E_long: 0.97 +B_lat: 8.0 +C_lat: 1.3 +D_lat: 1.0 +E_lat: -1.0 + +mu_nominal: 1.0 +Fz_nominal: 4000.0 +cornering_stiffness: 80000.0 +rolling_resistance: 0.0 +combined_slip_enabled: true +pneumatic_trail: 0.05 +trail_falloff_alpha: 0.2 +camber_stiffness: 0.0 +load_sensitivity: 0.0 +relaxation_length_lat: 0.0 +relaxation_length_long: 0.0 +tire_vertical_stiffness: 220000.0 +reff_breff: 0.0 +reff_dreff: 0.0 +reff_freff: 0.0 +crown_radius: 0.0 +vlow_speed_eps: 0.15 +low_speed_mode: kinematic_blend + +lugre: + enabled: true + sigma0: 90000.0 + sigma1: 0.0 + sigma2: 75.0 + m_eff: 40.0 diff --git a/configs/rl/default_env.yaml b/configs/rl/default_env.yaml index 417facf2..604913a1 100644 --- a/configs/rl/default_env.yaml +++ b/configs/rl/default_env.yaml @@ -2,6 +2,18 @@ # Observation layout is this list, left to right; a bare per-wheel name expands # to four columns in FL, FR, RL, RR order. level: L2 +# car: configs/vehicles/.yaml + configs/parts/tire/.yaml. +# generic_sedan + generic_pacejka are the C++ built-in defaults written out +# as files, so a run records the car it used instead of leaving it unnamed +# (PO decision 78). G1 substep accuracy at the 1 ms below, same table as +# fast_env.yaml (a): ay 0.55 %, yaw 0.20 %, Fz_FL 1.22 %. +# +# To run a car whose parameters are not in this repository, put it under +# $VDSIM_PRIVATE_CONFIGS/vehicles/.yaml (tyre under +# $VDSIM_PRIVATE_CONFIGS/parts/tire/) and name the stem here, or pass an +# absolute vehicle_file / tire_file. reset() then reports source=private. +vehicle: generic_sedan +tire: generic_pacejka dt: 0.005 action_repeat: 4 # control interval = 20 ms max_steer: 0.5 # [rad] at the wheel diff --git a/configs/rl/fast_env.yaml b/configs/rl/fast_env.yaml index 54389146..43dc2a7e 100644 --- a/configs/rl/fast_env.yaml +++ b/configs/rl/fast_env.yaml @@ -1,8 +1,14 @@ -# vdsim_rl training preset: same plant as default_env.yaml, coarser substep. +# vdsim_rl training preset: fast prototyping on the C++ built-in generic car at +# a coarser substep. default_env.yaml keeps the same car at 1 ms. # -# G1 gate (2026-09-17, L3, outer tick 5 ms, reference = same plant at -# max_substep_dt 0.1 ms). Worst max-relative error over the manoeuvre, -# throughput = 64 envs on 22 threads of an i7-13700K: +# G1 gate, outer tick 5 ms, reference = same plant at a finer max_substep_dt +# (0.1 ms for the rows marked [ref 0.1 ms], 1 us for [ref 1 us]). +# Worst max-relative error over the manoeuvre, throughput = 64 envs on 22 +# threads of an i7-13700K. Reproduce: tests/rl/gate_g1_substep_accuracy.py. +# Criterion fixed before the measurement: ay and yaw <= 2 %, Fz_FL <= 5 %. +# +# (a) C++ built-in generic car (= configs/vehicles/generic_sedan.yaml), L3, +# 2026-09-17. This is the table the 2.5 ms choice below was made on. # # substep step-steer ay step-steer yaw brake Fz_FL throughput # 0.50 ms 0.23 % 0.08 % 0.51 % 80 k steps/s @@ -10,14 +16,48 @@ # 2.50 ms 1.38 % 0.37 % 4.07 % 377 k steps/s <- this file # 5.00 ms 6.82 % 0.82 % 6.69 % 722 k steps/s (rejected) # -# Adopted: 2.5 ms for training (2.4x throughput, trajectory signals within -# 1.4 %). Keep default_env.yaml (1 ms) for evaluation and for any reward that -# reads per-wheel load - Fz is the signal that degrades first. 5 ms is not -# adopted: ay and Fz both pass 6 %. +# Same car at L2 (this file's level), 2026-09-22, same script: +# 2.50 ms 1.45 % 0.53 % 4.07 % 326 k steps/s <- this file +# 1.00 ms 0.55 % 0.20 % 1.22 % 131 k steps/s +# (not in the criterion: brake pitch 30.6 % and step-steer roll 8.4 % at +# 2.5 ms, 9.2 % / 1.5 % at 1 ms - do not build a reward on pitch or roll.) +# +# Rows above are [ref 0.1 ms]. Same car, L2, same manoeuvres and error +# definition, reference re-integrated at 1 us (Q23-h, 2026-09-22, +# solver_substep_clamp_count() == 0) [ref 1 us]: +# substep step-steer ay step-steer yaw brake Fz_FL pitch roll +# 0.01 ms 0.005 % 0.002 % 0.010 % 0.08 % 0.01 % +# 0.10 ms 0.059 % 0.022 % 0.117 % 0.88 % 0.16 % +# 1.00 ms 0.61 % 0.22 % 1.34 % 10.1 % 1.7 % +# 2.50 ms 1.51 % 0.55 % 4.19 % 31.5 % 8.5 % <- this file +# Observed order p = log10(E(h)/E(h/10)) is 1.01-1.06 between 1 and +# 0.1 ms and 1.04-1.05 between 0.1 and 0.01 ms (first order, monotone), +# so the 1 us reference is sound and the old 0.1 ms reference was +# itself off by the 0.10 ms row: the [ref 0.1 ms] rows read about 3-11 % +# (relative) too optimistic. The 2.5 ms row still passes the criterion. +# +# Adopted (PO decision 69, 2026-09-22): 2.5 ms for training - 2.4x the +# throughput of 1 ms with trajectory signals within 1.5 % and Fz_FL 4.07 %. +# Keep default_env.yaml (1 ms) for evaluation and for any reward that reads +# per-wheel load - Fz is the signal that degrades first. 5 ms is not adopted: +# ay and Fz both pass 6 %. +# +# Both RL declarations name the generic car explicitly since PO decision 78: +# configs/vehicles/generic_sedan.yaml carries the same values the C++ defaults +# compile in, so table (a) describes both files. +# +# A substep is a property of the car, not of the solver. Presets whose specs +# are not public are selected by the same keys but injected from outside the +# repository ($VDSIM_PRIVATE_CONFIGS); their G1 tables are kept with them, not +# here. Re-run the script above for any preset you swap in - do not carry this +# file's 2.5 ms over to it. # # max_substeps must stay above outer_dt / max_substep_dt, otherwise the solver # silently clamps and runs a coarser step than requested. level: L2 +# car: the generic car of table (a), named rather than left unset. +vehicle: generic_sedan +tire: generic_pacejka dt: 0.005 action_repeat: 4 max_steer: 0.5 diff --git a/configs/vehicles/generic_sedan.yaml b/configs/vehicles/generic_sedan.yaml new file mode 100644 index 00000000..fd038fd8 --- /dev/null +++ b/configs/vehicles/generic_sedan.yaml @@ -0,0 +1,65 @@ +# Generic mid-size sedan -- the values core/src/params.cpp compiles in as the +# VehicleParams() defaults, written out so a run can name the car it used +# instead of leaving `vehicle` unset. +# +# Source: vdsim.VehicleParams().to_yaml() (core/src/params.cpp +# VehicleParams::to_yaml), 2026-09-22. This is a solver default set, not a +# measured vehicle: do not cite any number here as a specification of any real +# car. tests/rl/test_rl_env.py (Q23-f1) compares every bound field of the +# loaded params against vdsim.VehicleParams() and fails if the two drift apart. +mass: 1500.0 +mass_sprung: 1350.0 +inertia_diag: [500.0, 2000.0, 2500.0] +wheelbase: 2.7 +cg_to_front: 1.2 +cg_to_rear: 1.5 +track_front: 1.55 +track_rear: 1.55 +cg_height: 0.55 +wheel_radius_nominal: 0.32 + +spring_stiffness: [30000.0, 30000.0, 30000.0, 30000.0] +damper_coefficient: [3000.0, 3000.0, 3000.0, 3000.0] +unsprung_mass: [40.0, 40.0, 40.0, 40.0] +wheel_inertia: [0.0, 0.0, 0.0, 0.0] # 0 = core derives a disc from radius/mass +arb_stiffness_front: 0.0 +arb_stiffness_rear: 0.0 +roll_center_height_front: 0.0 +roll_center_height_rear: 0.0 +anti_dive_front: 0.0 +anti_squat_rear: 0.0 +camber_per_roll: 0.0 + +drive_type: RWD +drive_split_front: 0.5 +differential: Open +lsd_preload: 0.1 +lsd_ramp: 0.2 +plant_path: false + +max_motor_torque: 300.0 +final_drive_ratio: 5.0 +engine_rotational_inertia: 0.25 +max_brake_torque: 2000.0 +brake_bias_front: 0.5 +brake_ebd_enabled: false +brake_deadtime_s: 0.0 +drive_deadtime_s: 0.0 +steer_deadtime_s: 0.0 + +steering_ratio: 15.0 +max_steer_angle_wheel: 0.5 +ackerman_percent: 0.0 + +aero_drag_coeff: 0.3 +frontal_area: 2.2 +aero_lift_front: 0.0 +aero_lift_rear: 0.0 + +tire_yaml: parts/tire/generic_pacejka.yaml + +# Drawn-primitive sizes for the 3D replay renderer (trace schema 0.3 geometry). +# Placeholder box for a generic sedan, no source, not a specification; no +# equation reads them. +body_lwh_m: [4.6, 1.8, 1.45] +wheel_width_m: 0.225 diff --git a/cosim/CMakeLists.txt b/cosim/CMakeLists.txt index a74c3448..160ff267 100644 --- a/cosim/CMakeLists.txt +++ b/cosim/CMakeLists.txt @@ -147,6 +147,16 @@ if(Python3_FOUND) set_tests_properties(vla_plant_demo PROPERTIES WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};PYTHONPATH=${CMAKE_BINARY_DIR}/python:${CMAKE_SOURCE_DIR}/python") + # vdsim_rl: torch-free checks only (gymnasium API, termination codes, obs + # buffer, vehicle preset input). A missing gymnasium fails the test; the + # SB3 PPO smoke (tests/rl/smoke_sb3_ppo.py) stays outside ctest. + add_test(NAME rl_env + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_SOURCE_DIR}/tests/rl/test_rl_env.py) + set_tests_properties(rl_env PROPERTIES + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + TIMEOUT 300 + ENVIRONMENT "VDSIM_BUILD_DIR=${CMAKE_BINARY_DIR};VDSIM_BUILD_PY=${CMAKE_BINARY_DIR}/python;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) diff --git a/docs/RL_GUIDE.md b/docs/RL_GUIDE.md new file mode 100644 index 00000000..13abfb0d --- /dev/null +++ b/docs/RL_GUIDE.md @@ -0,0 +1,319 @@ +# Reinforcement learning with VDSim (`vdsim_rl`) + +`vdsim_rl` is a Gymnasium-compatible environment layer over the C++ core. One +`VDSimVecEnv` owns *N* independent vehicle plants and a persistent worker pool, +so a policy can drive dozens of cars at once and collect experience from all of +them in a single call. + +## 1. Background — what it is for + +Learning a driving controller needs many rollouts, and the rollouts are only +worth having if the car in them behaves like a real car. VDSim supplies both: + +- **Vehicle dynamics, not a kinematic toy.** Every env runs one of the VDSim + plant models with tyre forces, load transfer and the actuator path. +- **Parallel rollouts.** A vector step is one GIL-released C++ call + (`VecSession.advance`) that ticks every env, evaluates termination and writes + the flat `float32` observation rows in place. Python never touches per-tick + data. + +| `level` | Plant | Use it when | Cost | +|---|---|---|---| +| `L1` | bicycle (single-track) | lateral behaviour only, fastest iteration | lowest | +| `L2` | 7-DOF (planar body + 4 wheel spins) | default for training | medium | +| `L3` | 14-DOF (adds roll, pitch, heave, suspension) | reward reads per-wheel load or body attitude | highest | + +Higher levels are more faithful and slower; section 6 has measured throughput. + +## 2. Concepts + +### 2.1 Vehicle + +`EnvConfig.vehicle` and `EnvConfig.tire` name preset files by stem: +`configs/vehicles/.yaml` and `configs/parts/tire/.yaml`. The +shipped RL configs (`configs/rl/default_env.yaml`, `configs/rl/fast_env.yaml`) +select the generic car: + +```yaml +vehicle: generic_sedan +tire: generic_pacejka +``` + +`generic_sedan` / `generic_pacejka` are the values `core/src/params.cpp` +compiles in as `VehicleParams()` / `TireParams()`, written out as files so a +run records the car it used. They are a solver default set, not a measured +vehicle. + +- The preset is read **once, in the parent process**, and shared by every env. + No file is written. +- A key the C++ parser would ignore (a typo), a missing required key (mass, + wheelbase, CG position, tracks, CG height, wheel radius; tyre `mu_nominal`, + `Fz_nominal`), or a catalog-format part file (`schema:` / `body:`) raises + `ValueError` — it never falls back to generic defaults silently. +- `vehicle=None` (the `EnvConfig()` dataclass default, kept for old code) uses + the C++ built-in generic car and emits + `UserWarning: vehicle=None: C++ built-in generic parameters`. +- A car whose parameters must stay out of this repository goes in a private + root with the same layout: set `VDSIM_PRIVATE_CONFIGS=/abs/root` and keep + `/vehicles/.yaml` (+ `/parts/tire/.yaml`), or pass + `EnvConfig(vehicle="", vehicle_file="/abs/path.yaml")`. Search order: + `vehicle_file` (absolute only) -> `$VDSIM_PRIVATE_CONFIGS` -> `configs/`. +- `reset()` returns `info = {"vehicle", "tire", "param_hash", "source"}` and + the same dict is in `env.metadata["vdsim"]`. `param_hash` is a sha256 over + the parameters the core actually received (plus the `.tir` file bytes), so a + training log can prove which car it trained on; `source` is `public` for a + preset read from `configs/` and `private` for anything else. Neither the + info dict nor the metadata carries a path. + +### 2.2 Observation + +`obs_fields` lists the observation columns left to right; a per-wheel name +expands to four columns in FL, FR, RL, RR order (`slip_ratio` becomes +`slip_ratio.0 … slip_ratio.3`). `env.obs_columns` gives the expanded names. +Frame: ISO 8855 (x forward, y left, z up), angles in radians. + +| Field | Unit | Meaning | +|---|---|---| +| `vx`, `vy` | m/s | body-frame velocity | +| `yaw_rate` | rad/s | yaw rate, + = turning left | +| `beta` | rad | side-slip angle | +| `ay` | m/s² | body lateral acceleration, + = left | +| `x`, `y`, `z` | m | position in the odom frame; `y` is the lateral offset from the road centre | +| `yaw` | rad | heading in the odom frame | +| `slip_ratio` | – | per wheel | +| `slip_angle` | rad | per wheel | +| `fz` | N | per-wheel vertical load | +| `wheel_mu` | – | per wheel, as reported in `SimOutput.wheel_mu` | +| `tire_fx`, `tire_fy` | N | per-wheel tyre forces in the wheel frame | +| `steer_applied`, `throttle_applied`, `brake_applied` | rad, –, – | commands after the actuator | + +### 2.3 Action + +`Box(-1, 1, shape=(2,))` per env: + +- `a[0]` steer, scaled to `±max_steer` rad at the wheel (default 0.5 rad); +- `a[1]` pedal, `> 0` throttle, `< 0` brake. + +Actions are clipped to `[-1, 1]`. One action is held for `action_repeat` +physics ticks (default 4 × 5 ms = 20 ms control interval). + +### 2.4 Reward + +`default_reward` keeps the lane and holds speed, per control step: + +``` +r = 1 - |y| / max_lateral - 0.05 |vx - speed_target| - 0.2 |beta| - 0.05 |Δsteer| +``` + +minus `crash_penalty` (default 10) on a terminating step. Pass +`reward_fn(obs, col, action, prev_action, cfg) -> (N,)` to replace it. + +### 2.5 Episode end + +| Reason | Condition (`<= 0` disables) | Gymnasium flag | +|---|---|---| +| `off_track` | `|y - lane_y| > max_lateral` | terminated | +| `rollover` | `|roll| > max_roll` | terminated | +| `spin_out` | `|beta| > max_beta` or `|yaw_rate| > max_yaw_rate` | terminated | +| `stall` | planar speed `< min_speed` | terminated | +| `time_limit` | episode sim time `>= time_limit_s` | truncated | + +A non-finite state ends the episode as `nan_state` (checked first). The checks +run in the order rollover, off_track, spin_out, stall, time_limit; the first hit +is the reported reason. `info["termination_reason"]` +names the reason; `VDSimVecEnv` auto-resets finished envs and keeps the last +observation in `info["final_observation"]`. + +### 2.6 Reset randomization + +Every reset draws, per env: initial speed from `speed_range` [m/s], lateral +offset `±lateral_range` [m] and heading `±yaw_range` [rad]. + +### 2.7 Domain randomization + +`(lo, hi)` multipliers drawn per env at every reset, `(1, 1)` = off: +`mass_scale_range` (mass, sprung mass and inertia), `mu_scale_range` (road +friction), `tire_stiffness_scale_range` (tyre B coefficients and cornering +stiffness), `tire_mu_scale_range` (tyre peak mu), and +`sensor_delay_range` [s] (`< 0` keeps the configured delay). The multipliers +scale the **loaded preset** — with `vehicle: generic_sedan`, `mass_scale 1.1` +means 1.1 × 1500 kg; with any other preset it is 1.1 × that preset's mass, +never 1.1 × the built-in default. + +### 2.8 Parallelism and seeds + +`num_envs` plants share one C++ worker pool; `threads: 0` uses every hardware +thread. `reset(seed=s)` gives env *i* its own Python stream (from +`SeedSequence(s).spawn`) and core noise seed `s + i`, so a run is reproducible +env by env. + +## 3. Quick start + +```bash +pip install vdsim gymnasium # or a locally built wheel; add stable-baselines3 for 4.2 +``` + +```python +from vdsim_rl import EnvConfig, VDSimEnv + +env = VDSimEnv(EnvConfig(vehicle="generic_sedan", tire="generic_pacejka"), seed=0) +obs, info = env.reset(seed=0) +print(info["vehicle"], env.obs_columns) +for _ in range(200): + obs, reward, terminated, truncated, info = env.step(env.action_space.sample()) + if terminated or truncated: + print("ended:", info["termination_reason"]) + obs, info = env.reset() +``` + +## 4. Examples + +### 4.1 Random policy on 16 envs + +```python +from vdsim_rl import EnvConfig, VDSimVecEnv + +cfg = EnvConfig.from_yaml("configs/rl/default_env.yaml") # generic car, 1 ms +env = VDSimVecEnv(16, cfg, seed=0) +obs, info = env.reset(seed=0) # obs: (16, obs_dim) +for _ in range(500): + obs, rew, term, trunc, info = env.step(env.action_space.sample()) + if "termination_reason" in info: # auto-reset done + print([r for r in info["termination_reason"] if r]) +``` + +### 4.2 Short PPO run (Stable-Baselines3, optional dependency) + +```python +from stable_baselines3 import PPO +from vdsim_rl import EnvConfig, make_sb3_vec_env + +cfg = EnvConfig.from_yaml("configs/rl/fast_env.yaml") +venv = make_sb3_vec_env(16, cfg, seed=0) +model = PPO("MlpPolicy", venv, n_steps=256, batch_size=1024, verbose=1, device="cpu") +model.learn(total_timesteps=100_000) +model.save("ppo_lanekeep") +``` + +`make_sb3_vec_env` implements SB3's own `VecEnv` protocol directly, so no +`DummyVecEnv` wrapper (and no per-env Python object) is involved. + +### 4.3 Domain randomization: road friction 0.6–1.0 + +```python +from vdsim_rl import EnvConfig, VDSimVecEnv + +cfg = EnvConfig.from_yaml("configs/rl/default_env.yaml") +cfg.mu_scale_range = (0.6, 1.0) +cfg.mass_scale_range = (0.9, 1.1) +env = VDSimVecEnv(64, cfg, seed=1) +env.reset(seed=1) +print([round(d.mu_scale, 2) for d in env.core.randomization[:8]]) # this reset's draws +``` + +`configs/rl/fast_env.yaml` already turns on mass (0.85–1.15), friction +(0.6–1.0) and tyre stiffness (0.85–1.15) randomization. + +## 5. Demo videos + +Both videos draw every frame from a recorded plant state — no interpolation, +no synthesised frames. The caption in each frame names vehicle, tyre, level, +N, policy, seed, config and commit. Reproduce with the two scripts in +`examples/rl/` (system `ffmpeg` needed for mp4; without it the scripts write +PNG frames and print the encode command). + +**A — gym grid** ([`assets/rl/vec_grid.mp4`](assets/rl/vec_grid.mp4)): +16 envs, random policy, 20 s. + + + +What to look at: each tile keeps its own episode counter and return; when a car +crosses the grey band's edge the tile flags `reset <- off_track` and the car +restarts from a new random speed, offset and heading. + +```bash +python examples/rl/record_vec_demo.py --config configs/rl/fast_env.yaml \ + --num-envs 16 --seconds 20 --npz /tmp/rl_grid.npz +python examples/rl/render_vec_grid.py /tmp/rl_grid.npz --out docs/assets/rl/vec_grid.mp4 +``` + +**B — one road, 64 cars** ([`assets/rl/vec_overlay.mp4`](assets/rl/vec_overlay.mp4)): +the first episode of 64 envs overlaid, 15 s. Every env runs the same fixed +proportional lane-keeper (a scripted baseline, not a trained policy) plus its +*own* random steer disturbance and pedal, drawn from a per-env generator +(`SeedSequence(seed).spawn(64)`), on top of reset randomization and the +`fast_env.yaml` domain randomization. + + + +What to look at: 64 independent rollouts — cars that leave the road (grey +marker = episode ended) do so on both sides, and the rest settle at different +distances because their inputs, initial speed and friction differ. For the +spread from randomization alone, record with `--shared-actions`. + +```bash +python examples/rl/record_vec_demo.py --config configs/rl/fast_env.yaml \ + --num-envs 64 --seconds 15 --seed 3 --policy lanekeep --steer-scale 0.3 \ + --hold 1.0 --npz /tmp/rl_overlay.npz +python examples/rl/render_vec_grid.py /tmp/rl_overlay.npz --overlay \ + --out docs/assets/rl/vec_overlay.mp4 +``` + +Both videos ship as recorded by those two commands on the generic car of +`fast_env.yaml`. The caption carries the commit of the tree they were recorded +from, so running the commands at that commit reproduces them. + +`record_vec_demo.py --trace-dir` writes one `.vdtrace` per env instead, which +`vdsim-render` overlays with its full per-run panels; that view reads well for +a handful of runs, not for 64. + +## 6. Limits + +Throughput below is in **physics ticks per second summed over all envs** +(one tick = one `dt` step of one plant; a control step is `action_repeat` ticks). + +- **Throughput (reference box, i7-13700K 8P+16E).** Core 378 k ticks/s at 22 + threads with the C++ generic car, 93.7 % of it retained through the Python + adapter; end-to-end PPO reaches 61 k ticks/s because the CPU policy update + dominates (`docs/ROADMAP.md` §10, `tests/rl/bench_throughput_layers.py`). +- **`fast_env.yaml`'s substep was chosen on a criterion fixed before the + measurement:** ay and yaw-rate ≤ 2 %, front-left vertical load ≤ 5 % worst + error against a finer-step reference of the same plant (L2, 5 ms tick, + 2026-09-22). The reference was first 0.1 ms, then re-integrated at + 1 µs; both are shown: + + | car | reference | substep | ay | yaw rate | Fz_FL | throughput (64 envs) | verdict | + |---|---|---|---|---|---|---|---| + | generic car | 0.1 ms | 1.0 ms | 0.55 % | 0.20 % | 1.22 % | 131 k steps/s | passes = `default_env.yaml` | + | generic car | 0.1 ms | 2.5 ms | 1.45 % | 0.53 % | 4.07 % | 326 k steps/s | passes = `fast_env.yaml` | + | generic car | 1 µs | 1.0 ms | 0.61 % | 0.22 % | 1.34 % | (as above) | passes | + | generic car | 1 µs | 2.5 ms | 1.51 % | 0.55 % | 4.19 % | (as above) | passes | + + The error falls first-order with the substep (observed order 1.01–1.06 + from 1 ms down to 0.01 ms, against the 1 µs reference), so the 0.1 ms + reference carried its own 0.06 % (ay) / 0.12 % (Fz_FL) error and the + 0.1 ms rows are slightly optimistic; the verdicts do not change. + + `fast_env.yaml` takes the 2.5 ms rows for 2.4× the throughput; 5 ms was + rejected on the L3 table in `configs/rl/fast_env.yaml` (ay 6.82 %, Fz + 6.69 %). Both shipped files name the generic car (`generic_sedan` + + `generic_pacejka`), which is why neither warns about unnamed parameters. + Pitch and roll are not in the criterion and are coarse at 2.5 ms (brake pitch + 30.6 %, step-steer roll 8.4 %); do not build a reward on them with + `fast_env.yaml`. +- **A different car changes both accuracy and throughput, so the substep does + not transfer.** A preset that evaluates a measured `.tir` file through the + MF2002 backend instead of the built-in MF96 has been measured ≈ 2.7× slower + at the same substep, with a larger vertical-load error at 2.5 ms. Run + `tests/rl/gate_g1_substep_accuracy.py --vehicle --tire ` on any + preset you swap in before training on it. +- **Presets whose specs are not public.** Point `VDSIM_PRIVATE_CONFIGS` at a + directory outside the repository (`vehicles/.yaml`, + `parts/tire/.yaml`), or pass `vehicle_file=` an absolute path. The + private copy wins over a repository file of the same stem, and `info` + records only the stem, the parameter hash and `source: private` — never the + path. Accuracy and throughput tables for those presets belong with them, not + in this guide. +- The road is straight and flat; there is no track or traffic yet. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 547e58d2..b5e53b3e 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -20,7 +20,7 @@ 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: **506/506 ctest green (as of 2026-09-22)** (`validation` preset — see the currency block below) | — | +| Full suite: **507/507 ctest green (as of 2026-09-22)** (`validation` preset — see the currency block below) | — | | 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) | @@ -48,11 +48,11 @@ version and the new numbers have to land in the same commit. ```text -tests: 506/506 +tests: 507/507 config: cmake --preset validation && cmake --build --preset validation && ctest --preset validation presets: CMakePresets.json@fb1f0c198a0201fcce2543a9f5b2242a255fb97a toolchain: cmake 3.31.10 -commit: 6e2592f +commit: 1d20a34 date: 2026-09-22 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) diff --git a/docs/assets/rl/vec_grid.mp4 b/docs/assets/rl/vec_grid.mp4 new file mode 100644 index 00000000..34eeab47 Binary files /dev/null and b/docs/assets/rl/vec_grid.mp4 differ diff --git a/docs/assets/rl/vec_grid_end.png b/docs/assets/rl/vec_grid_end.png new file mode 100644 index 00000000..2ad9512f Binary files /dev/null and b/docs/assets/rl/vec_grid_end.png differ diff --git a/docs/assets/rl/vec_grid_reset.png b/docs/assets/rl/vec_grid_reset.png new file mode 100644 index 00000000..6dca0bb6 Binary files /dev/null and b/docs/assets/rl/vec_grid_reset.png differ diff --git a/docs/assets/rl/vec_grid_start.png b/docs/assets/rl/vec_grid_start.png new file mode 100644 index 00000000..9378e263 Binary files /dev/null and b/docs/assets/rl/vec_grid_start.png differ diff --git a/docs/assets/rl/vec_overlay.mp4 b/docs/assets/rl/vec_overlay.mp4 new file mode 100644 index 00000000..c0976af1 Binary files /dev/null and b/docs/assets/rl/vec_overlay.mp4 differ diff --git a/docs/assets/rl/vec_overlay_end.png b/docs/assets/rl/vec_overlay_end.png new file mode 100644 index 00000000..f821d012 Binary files /dev/null and b/docs/assets/rl/vec_overlay_end.png differ diff --git a/examples/rl/record_vec_demo.py b/examples/rl/record_vec_demo.py new file mode 100755 index 00000000..5a334d69 --- /dev/null +++ b/examples/rl/record_vec_demo.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Record a vdsim_rl vector-env rollout for the RL demo videos. + +Every stored sample is the plant state the env produced at that control step; +nothing is interpolated or synthesised. Two outputs, pick either or both: + +* ``--npz``: per-step arrays for all envs across auto-resets (video A, the + gym grid: ``render_vec_grid.py``). +* ``--trace-dir``: one ``.vdtrace`` per env covering its *first* episode only, + for ``vdsim-render`` / ``vdsim_render.render_multi``. That overlay has one + legend row and one HUD row per run, so it reads well up to a handful of runs; + for dozens use ``render_vec_grid.py --overlay`` on the ``--npz`` output. + +Example:: + + python examples/rl/record_vec_demo.py --config configs/rl/fast_env.yaml \\ + --num-envs 16 --seconds 20 --npz /tmp/rl_grid.npz + python examples/rl/record_vec_demo.py --config configs/rl/fast_env.yaml \\ + --num-envs 64 --seconds 15 --seed 3 --policy lanekeep --steer-scale 0.3 \\ + --hold 1.0 --npz /tmp/rl_overlay.npz + +The policy is piecewise-constant uniform random: a new action is drawn from +the action space every ``--hold`` seconds and held in between (a per-step +uniform draw averages out to driving straight and shows nothing). The steer +component is multiplied by ``--steer-scale``. By default every env draws from +its own generator (``SeedSequence(seed).spawn(num_envs)``), so no two envs +share a disturbance and env i sees the same one whatever ``--num-envs`` is. +With ``--shared-actions`` every env receives the *same* action sequence +instead, so any spread comes from reset and domain randomization alone. + +``--policy lanekeep`` replaces the random steer by one fixed proportional +lane-keeping law for every env, ``steer = -(k_y*y + k_yaw*yaw)`` on the env's +own observation, plus the random action scaled by ``--steer-scale`` as a +steer disturbance, and keeps the random pedal. It is a scripted baseline, not +a trained policy; it keeps cars on the road long enough for the randomization +spread to show. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +import numpy as np + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "python")) + +import vdsim # noqa: E402 +from vdsim_rl import TERM_NAMES, EnvConfig, VDSimVecEnv # noqa: E402 + + +def git_sha() -> str: + """Short commit of the tree the demo ran from, or ``"unknown"``. + + ``-dirty`` is appended when the work tree differs from that commit + (modified or untracked files): the commit alone would then not reproduce + the recording. + """ + try: + run = lambda *a: subprocess.run(["git", "-C", str(REPO), *a], capture_output=True, + text=True, check=True).stdout.strip() + return run("rev-parse", "--short", "HEAD") + ("-dirty" if run("status", "--porcelain") else "") + except Exception: + return "unknown" + + +def random_policy(num_envs: int, hold_steps: int, seed: int, + steer_scale: float = 1.0, shared: bool = False): + """Piecewise-constant uniform random actions in [-1, 1]^2. + + :param steer_scale: multiplier on the steer component. + :param shared: one draw broadcast to every env instead of one generator per env. + :returns: callable ``k -> (num_envs, 2)`` float32 action for control step k. + """ + if shared: + rngs = [np.random.default_rng(seed)] + else: + rngs = [np.random.default_rng(c) for c in np.random.SeedSequence(seed).spawn(num_envs)] + held = np.zeros((num_envs, 2), dtype=np.float32) + + def act(k: int) -> np.ndarray: + if k % hold_steps == 0: + held[:] = np.stack([r.uniform(-1.0, 1.0, size=2) for r in rngs]) + held[:, 0] *= steer_scale + return held.copy() + return act + + +def lanekeep_policy(num_envs: int, hold_steps: int, seed: int, col: dict, + max_steer: float, k_y: float, k_yaw: float, shared: bool, + steer_scale: float = 0.0): + """Fixed P lane-keeper on (y, yaw) + random steer disturbance and pedal. + + :param col: observation column index map of the env. + :returns: callable ``(k, obs) -> (num_envs, 2)`` action. + """ + rand = random_policy(num_envs, hold_steps, seed, steer_scale, shared) + + def act(k: int, obs: np.ndarray) -> np.ndarray: + a = rand(k) + steer = -(k_y * obs[:, col["y"]] + k_yaw * obs[:, col["yaw"]]) + a[:, 0] = np.clip(steer / max_steer + a[:, 0], -1.0, 1.0) + return a + return act + + +def sample_env(sess) -> dict: + """One env's plant state, same sources as ``VDSimPlant._record``.""" + o = sess.output() + st = o.state + return { + "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": float(o.steer_applied), + "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)], + } + + +def record(cfg: EnvConfig, num_envs: int, seconds: float, seed: int, hold_s: float, + steer_scale: float = 1.0, shared: bool = False, policy_name: str = "random", + k_y: float = 0.02, k_yaw: float = 0.3): + """Run the env; auto-reset is done here so the terminal state is kept. + + ``env.core.step`` / ``env.core.reset_done`` are the two halves of + ``VDSimVecEnv.step``; calling them separately lets the recorder store the + state *at* termination before the env is reset. + + :returns: (env, list of per-step dicts) + """ + env = VDSimVecEnv(num_envs, cfg, seed=seed) + env.reset(seed=seed) + ctrl_dt = cfg.dt * cfg.action_repeat + n_steps = int(round(seconds / ctrl_dt)) + hold = max(1, int(round(hold_s / ctrl_dt))) + if policy_name == "lanekeep": + lk = lanekeep_policy(num_envs, hold, seed + 1, env.core.col, cfg.max_steer, + k_y, k_yaw, shared, steer_scale) + policy = lambda k: lk(k, env.core.obs) + else: + policy = random_policy(num_envs, hold, seed + 1, steer_scale, shared) + episode = np.zeros(num_envs, dtype=np.int64) + ep_t = np.zeros(num_envs) + steps = [{"k": 0, "episode": episode.copy(), "ep_t": ep_t.copy(), + "ep_return": np.zeros(num_envs), "code": np.zeros(num_envs, np.int32), + "env": [sample_env(env.core.vs.at(i)) for i in range(num_envs)]}] + for k in range(1, n_steps + 1): + _, _, term, trunc, codes = env.core.step(policy(k - 1)) + ep_t += ctrl_dt + done = term | trunc + steps.append({"k": k, "episode": episode.copy(), "ep_t": ep_t.copy(), + "ep_return": env.core.ep_ret.copy(), "code": codes.copy(), + "env": [sample_env(env.core.vs.at(i)) for i in range(num_envs)]}) + if done.any(): + env.core.reset_done(done) + episode[done] += 1 + ep_t[done] = 0.0 + return env, steps + + +def save_npz(path: Path, steps, meta: dict) -> None: + """Stack the per-step dicts into ``(T, N, ...)`` arrays.""" + arr = lambda key: np.array([[e[key] for e in s["env"]] for s in steps]) + np.savez_compressed( + path, k=np.array([s["k"] for s in steps]), + episode=np.array([s["episode"] for s in steps]), + ep_t=np.array([s["ep_t"] for s in steps]), + ep_return=np.array([s["ep_return"] for s in steps]), + code=np.array([s["code"] for s in steps]), + pose=arr("pose"), v_body=arr("v_body"), yaw_rate=arr("yaw_rate"), + u_steer=arr("u_steer"), meta=json.dumps(meta)) + + +def save_traces(out_dir: Path, steps, cfg: EnvConfig, env, meta: dict) -> list: + """One ``.vdtrace`` per env, first episode only (to and incl. termination).""" + import vdsim_trace + from vdsim_plant import CONTACT_SCOPE_BY_LEVEL, body_dimensions, \ + measure_mu_aniso, resolve_vehicle_config + + vp, tp = _params(cfg) + shape, aniso = measure_mu_aniso(tp) + body_lwh, wheel_width = body_dimensions(resolve_vehicle_config(cfg.vehicle)) + geometry = { + "wheelbase_m": float(vp.wheelbase), "track_m": float(vp.track_front), + "steer_ratio": float(vp.steering_ratio), + "track_front_m": float(vp.track_front), "track_rear_m": float(vp.track_rear), + "cg_to_front_m": float(vp.cg_to_front), "cg_to_rear_m": float(vp.cg_to_rear), + "wheel_radius_m": float(vp.wheel_radius_nominal), + "mass_kg": float(vp.mass), "cg_height_m": float(vp.cg_height), + "wheel_width_m": wheel_width, "body_lwh_m": body_lwh, + } + # u_fx is a pedal command here, not a force: omitted, never zero-filled. + channels = ("t", "pose", "v_body", "yaw_rate", "u_steer", "wheel_F", + "wheel_mu", "wheel_kappa", "wheel_alpha") + ctrl_dt = cfg.dt * cfg.action_repeat + out_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for i in range(env.num_envs): + w = vdsim_trace.TraceWriter( + out_dir / f"env{i:02d}.vdtrace", geometry=geometry, + tire={"friction_shape": shape, "mu_aniso": aniso, + "mu_aniso_source": "measured"}, + repro={"vdsim_version": getattr(vdsim, "__version__", "unknown"), + "git_sha": meta["commit"], "param_hash": meta["param_hash"], + "seed": meta["seed"], "dt_s": ctrl_dt, "run_id": f"env{i:02d}"}, + producer={"name": "record_vec_demo.py", "version": meta["commit"]}, + channels=channels, extra={"tags": {"rl_env_index": i, **meta["tags"]}}, + role="plant", model_level=cfg.level, + contact_scope=CONTACT_SCOPE_BY_LEVEL[cfg.level]) + for s in steps: + if s["episode"][i] > 0: + break + w.append({"t": s["k"] * ctrl_dt, **s["env"][i]}) + if s["code"][i] != vdsim.TERM_NONE: + break + paths.append(str(w.finalize())) + return paths + + +def _params(cfg: EnvConfig): + from vdsim_rl import load_vehicle_preset + vp, tp, _ = load_vehicle_preset(cfg.vehicle, cfg.tire) + return vp, tp + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--config", default=str(REPO / "configs/rl/fast_env.yaml")) + ap.add_argument("--num-envs", type=int, default=16) + ap.add_argument("--seconds", type=float, default=20.0) + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--hold", type=float, default=0.5, help="policy hold [s]") + ap.add_argument("--steer-scale", type=float, default=1.0) + ap.add_argument("--shared-actions", action="store_true", + help="same random draws for every env") + ap.add_argument("--policy", choices=("random", "lanekeep"), default="random") + ap.add_argument("--k-y", type=float, default=0.02, help="lanekeep [rad/m]") + ap.add_argument("--k-yaw", type=float, default=0.3, help="lanekeep [rad/rad]") + ap.add_argument("--vehicle-note", default="", + help="shown after the vehicle name in the caption") + ap.add_argument("--npz", type=Path) + ap.add_argument("--trace-dir", type=Path) + args = ap.parse_args(argv) + if not (args.npz or args.trace_dir): + ap.error("give --npz and/or --trace-dir") + + cfg = EnvConfig.from_yaml(args.config) + env, steps = record(cfg, args.num_envs, args.seconds, args.seed, args.hold, + args.steer_scale, args.shared_actions, args.policy, + args.k_y, args.k_yaw) + prov = env.core.provenance + codes = np.array([s["code"] for s in steps[1:]]) + reasons = {TERM_NAMES[c]: int((codes == c).sum()) for c in np.unique(codes) + if c != vdsim.TERM_NONE} + meta = {"vehicle": prov["vehicle"], "vehicle_note": args.vehicle_note, + "tire": prov["tire"], + "param_hash": prov["param_hash"], "level": cfg.level, + "num_envs": args.num_envs, "seconds": args.seconds, "seed": args.seed, + "policy": ((f"lanekeep P k_y={args.k_y:g} k_yaw={args.k_yaw:g} + random" + if args.policy == "lanekeep" else "random") + + (" shared" if args.shared_actions else " per-env") + + f", hold {args.hold:g} s" + + (f", steer x{args.steer_scale:g}" if args.steer_scale != 1.0 else "")), + "control_dt_s": cfg.dt * cfg.action_repeat, "config": Path(args.config).name, + "commit": git_sha(), "terminations": reasons, + "max_lateral_m": cfg.max_lateral, "lane_y_m": cfg.lane_y, + "tags": {"config": Path(args.config).name, "policy": args.policy}} + if args.npz: + save_npz(args.npz, steps, meta) + print(f"wrote {args.npz}") + if args.trace_dir: + paths = save_traces(args.trace_dir, steps, cfg, env, meta) + print(f"wrote {len(paths)} traces to {args.trace_dir}") + print(json.dumps(meta)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/rl/render_vec_grid.py b/examples/rl/render_vec_grid.py new file mode 100755 index 00000000..1840cca8 --- /dev/null +++ b/examples/rl/render_vec_grid.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Render a ``record_vec_demo.py --npz`` rollout: gym tile grid or one-road overlay. + +Grid (default): one tile per env, each a bird's-eye view that follows its car +along the straight training road. When an episode ends the tile shows the +terminal pose with the termination reason; the label stays for ``--label-s`` +seconds over the freshly reset car. + +Overlay (``--overlay``): every env's *first* episode on one road, cars as +translucent markers with their trails. An env whose episode has ended keeps +its terminal marker, greyed. A default (per-env disturbance) recording shows +independent inputs on top of reset and domain randomization; a +``--shared-actions`` recording isolates the randomization alone. + +A frame is drawn only at a recorded control step (``--every`` picks every n-th +one); nothing is interpolated. Encoding uses the system ``ffmpeg`` (not +bundled); without it the frames are written as PNGs and the command to encode +them is printed. + + python examples/rl/render_vec_grid.py /tmp/rl_grid.npz \\ + --out docs/assets/rl/vec_grid.mp4 + python examples/rl/render_vec_grid.py /tmp/rl_overlay.npz --overlay \\ + --out docs/assets/rl/vec_overlay.mp4 +""" +from __future__ import annotations + +import argparse +import json +import math +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "python")) + +import matplotlib # noqa: E402 +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.patches import Polygon # noqa: E402 + +from vdsim_rl import TERM_NAMES # noqa: E402 + + +def body_corners(x: float, y: float, yaw: float, length: float, width: float): + """Four corners of the body box centred on the CG (ISO 8855, +y left).""" + c, s = math.cos(yaw), math.sin(yaw) + pts = [(length / 2, width / 2), (length / 2, -width / 2), + (-length / 2, -width / 2), (-length / 2, width / 2)] + return [(x + c * px - s * py, y + s * px + c * py) for px, py in pts] + + +def caption(meta: dict, n: int, extra: str = "") -> str: + """Provenance burnt into every frame, two lines so it fits a 1280 px frame.""" + note = f" ({meta['vehicle_note']})" if meta.get("vehicle_note") else "" + return (f"vdsim_rl VDSimVecEnv N={n} vehicle={meta['vehicle']}{note} " + f"tire={meta['tire']} level={meta['level']}\n" + f"policy={meta['policy']} seed={meta['seed']} config={meta['config']} " + f"commit={meta['commit']}" + extra) + + +class Sink: + """Frames -> system ffmpeg (H.264) and/or a PNG directory.""" + + def __init__(self, fig, fps: int, out: Path, png_dir): + self.fig, self.fps, self.out = fig, fps, out + self.enc = None + if shutil.which("ffmpeg"): + w, h = fig.canvas.get_width_height() + out.parent.mkdir(parents=True, exist_ok=True) + self.enc = subprocess.Popen( + ["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", + "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-r", str(fps), "-i", "-", + # yuv420p needs even sides; pad by at most 1 px, never rescale + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-c:v", "libx264", + "-pix_fmt", "yuv420p", "-crf", "20", str(out)], stdin=subprocess.PIPE) + self.png_dir = png_dir if (png_dir or self.enc) else out.with_suffix("") + if self.png_dir: + self.png_dir.mkdir(parents=True, exist_ok=True) + self.n = 0 + + def write(self) -> None: + self.fig.canvas.draw() + if self.enc is not None: + self.enc.stdin.write(self.fig.canvas.buffer_rgba()) + if self.png_dir: + self.fig.savefig(self.png_dir / f"frame_{self.n:05d}.png") + self.n += 1 + + def close(self) -> None: + if self.enc is not None: + self.enc.stdin.close() + self.enc.wait() + print(f"wrote {self.out} ({self.n} frames @ {self.fps} fps)") + else: + print(f"ffmpeg not found; {self.n} PNGs in {self.png_dir}. Encode with:\n" + f" ffmpeg -r {self.fps} -i {self.png_dir}/frame_%05d.png " + f"-c:v libx264 -pix_fmt yuv420p {self.out}") + + +def render_grid(d, meta, args, body_l: float, body_w: float) -> None: + pose, code, episode, ep_ret = d["pose"], d["code"], d["episode"], d["ep_return"] + T, N = code.shape + dt = meta["control_dt_s"] + edge, lane_y = meta["max_lateral_m"], meta["lane_y_m"] + + rows = math.ceil(N / args.cols) + fig, axes = plt.subplots(rows, args.cols, figsize=(3.2 * args.cols, 1.9 * rows + 0.6), + dpi=args.dpi, squeeze=False) + fig.subplots_adjust(left=0.01, right=0.99, bottom=0.01, top=0.90, + wspace=0.04, hspace=0.18) + fig.suptitle(caption(meta, N), fontsize=8) + clock = fig.text(0.99, 0.925, "", ha="right", fontsize=8) + fig.text(0.01, 0.925, f"grey band = road, edges = off_track limit +-{edge:g} m", + ha="left", fontsize=8) + + tiles = [] + for i in range(rows * args.cols): + ax = axes[i // args.cols][i % args.cols] + ax.set_xticks([]); ax.set_yticks([]) + if i >= N: + ax.axis("off") + continue + ax.set_aspect("equal", adjustable="box") + ax.set_ylim(lane_y - edge - 1.5, lane_y + edge + 1.5) + ax.axhspan(lane_y - edge, lane_y + edge, color="0.85", zorder=0) + ax.axhline(lane_y + edge, color="0.3", lw=1.2, zorder=1) + ax.axhline(lane_y - edge, color="0.3", lw=1.2, zorder=1) + ax.axhline(lane_y, color="0.55", lw=0.8, ls=(0, (6, 6)), zorder=1) + trail, = ax.plot([], [], color="tab:blue", lw=0.8, alpha=0.6, zorder=2) + car = Polygon(np.zeros((4, 2)), closed=True, fc="tab:blue", ec="k", lw=0.6, zorder=3) + ax.add_patch(car) + hud = ax.text(0.01, 0.97, "", transform=ax.transAxes, va="top", fontsize=7, + family="monospace") + banner = ax.text(0.5, 0.08, "", transform=ax.transAxes, ha="center", + va="bottom", fontsize=8, color="white", weight="bold", + bbox=dict(fc="tab:red", ec="none", pad=1.5), visible=False) + tiles.append((ax, trail, car, hud, banner)) + + sink = Sink(fig, round(1.0 / (dt * args.every)), args.out, args.png_dir) + label_steps = int(round(args.label_s / dt)) + last_end = np.full(N, -10 ** 9) + last_reason = [""] * N + ep_start = np.zeros(N, dtype=int) + for k in range(T): + for i in range(N): + if k > 0 and code[k, i] != 0: # terminal pose recorded at k + last_end[i] = k + last_reason[i] = TERM_NAMES.get(int(code[k, i]), str(int(code[k, i]))) + if k > 0 and episode[k, i] != episode[k - 1, i]: + ep_start[i] = k + if k % args.every: + continue + for i, (ax, trail, car, hud, banner) in enumerate(tiles): + x, y, yaw = pose[k, i] + car.set_xy(body_corners(x, y, yaw, body_l, body_w)) + seg = slice(ep_start[i], k + 1) + trail.set_data(pose[seg, i, 0], pose[seg, i, 1]) + ax.set_xlim(x - args.view_behind, x + args.view_ahead) + ending = code[k, i] != 0 and k > 0 + car.set_facecolor("tab:red" if ending else "tab:blue") + hud.set_text(f"env {i:02d} ep {int(episode[k, i])}\nR {ep_ret[k, i]:+7.1f}") + show = 0 <= k - last_end[i] <= label_steps + banner.set_visible(bool(show)) + if show: + banner.set_text(("done: " if ending else "reset <- ") + last_reason[i]) + clock.set_text(f"t = {k * dt:5.2f} s (frame = recorded step {k})") + sink.write() + sink.close() + + +def render_overlay(d, meta, args, body_l: float, body_w: float) -> None: + pose, code, episode = d["pose"], d["code"], d["episode"] + T, N = code.shape + dt = meta["control_dt_s"] + edge, lane_y = meta["max_lateral_m"], meta["lane_y_m"] + # Last step of each env's first episode: its terminal step, else the end. + end = np.full(N, T - 1) + reason = [""] * N + for i in range(N): + hit = np.flatnonzero((code[1:, i] != 0) & (episode[1:, i] == 0)) + if hit.size: + end[i] = hit[0] + 1 + reason[i] = TERM_NAMES.get(int(code[end[i], i]), str(int(code[end[i], i]))) + + x_all = np.concatenate([pose[:end[i] + 1, i, 0] for i in range(N)]) + x0, x1 = float(x_all.min()) - 5.0, float(x_all.max()) + 8.0 + fig, ax = plt.subplots(figsize=(12.8, 4.6), dpi=args.dpi) + fig.subplots_adjust(left=0.05, right=0.99, bottom=0.13, top=0.83) + fig.suptitle(caption(meta, N, "\nfirst episode of every env on one road"), + fontsize=8) + ax.set_xlim(x0, x1) + ax.set_ylim(lane_y - edge - 1.5, lane_y + edge + 1.5) + stretch = ((x1 - x0) / (ax.get_position().width * 12.8)) / ( + (2 * edge + 3.0) / (ax.get_position().height * 4.6)) + ax.set_xlabel("x [m] (odom)") + ax.set_ylabel(f"y [m] (odom) -- drawn {stretch:.1f}x taller than x") + ax.axhspan(lane_y - edge, lane_y + edge, color="0.88", zorder=0) + for yy in (lane_y - edge, lane_y + edge): + ax.axhline(yy, color="0.3", lw=1.2, zorder=1) + ax.axhline(lane_y, color="0.55", lw=0.8, ls=(0, (6, 6)), zorder=1) + colors = plt.get_cmap("turbo")(np.linspace(0.05, 0.95, N)) + trails = [ax.plot([], [], color=colors[i], lw=0.8, alpha=0.35, zorder=2)[0] + for i in range(N)] + dots = ax.scatter(np.zeros(N), np.zeros(N), s=28, c=colors, alpha=0.6, + edgecolors="k", linewidths=0.3, zorder=3) + status = ax.text(0.005, 0.97, "", transform=ax.transAxes, va="top", fontsize=8, + family="monospace", bbox=dict(fc="white", ec="0.6", alpha=0.85)) + + sink = Sink(fig, round(1.0 / (dt * args.every)), args.out, args.png_dir) + for k in range(0, T, args.every): + idx = np.minimum(k, end) + xy = pose[idx, np.arange(N), :2] + dots.set_offsets(xy) + ended = k >= end + ended &= end < T - 1 + fc = colors.copy() + fc[ended] = (0.45, 0.45, 0.45, 1.0) + dots.set_facecolors(fc) + for i in range(N): + trails[i].set_data(pose[:idx[i] + 1, i, 0], pose[:idx[i] + 1, i, 1]) + counts = {} + for i in np.flatnonzero(ended): + counts[reason[i]] = counts.get(reason[i], 0) + 1 + done_txt = ", ".join(f"{n} {r}" for r, n in sorted(counts.items())) or "-" + status.set_text(f"t = {k * dt:5.2f} s driving {N - int(ended.sum()):2d}/{N}" + f" ended (grey): {done_txt}") + sink.write() + sink.close() + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("npz", type=Path) + ap.add_argument("--out", type=Path, required=True, help="output .mp4") + ap.add_argument("--overlay", action="store_true", + help="all envs' first episode on one road instead of tiles") + ap.add_argument("--png-dir", type=Path, help="also keep the frames as PNGs") + ap.add_argument("--every", type=int, default=2, help="draw every n-th step") + ap.add_argument("--cols", type=int, default=4) + ap.add_argument("--view-ahead", type=float, default=25.0, help="[m]") + ap.add_argument("--view-behind", type=float, default=10.0, help="[m]") + ap.add_argument("--label-s", type=float, default=1.0) + ap.add_argument("--body", type=float, nargs=2, default=None, + metavar=("L", "W"), help="body box [m]; default from the vehicle YAML") + ap.add_argument("--dpi", type=int, default=100) + args = ap.parse_args(argv) + + d = np.load(args.npz) + meta = json.loads(str(d["meta"])) + if args.body is None: + import yaml + from vdsim_plant import resolve_vehicle_config + lwh = yaml.safe_load(resolve_vehicle_config(meta["vehicle"]).read_text())["body_lwh_m"] + body_l, body_w = float(lwh[0]), float(lwh[1]) + else: + body_l, body_w = args.body + (render_overlay if args.overlay else render_grid)(d, meta, args, body_l, body_w) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mkdocs.yml b/mkdocs.yml index 9ee8c688..02a09a1d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - "Product roadmap": ROADMAP.md - "Catalog, scenes & physics": CATALOG_AND_PHYSICS.md - Running VDSim: RUNNING.md + - "RL environment (vdsim_rl)": RL_GUIDE.md - Validation: VALIDATION.md - References: - "Actuator/sensor nonlinearity & delay": references/actuator_nonlinearity.md diff --git a/python/vdsim_rl/__init__.py b/python/vdsim_rl/__init__.py index e67fef08..b99caa98 100644 --- a/python/vdsim_rl/__init__.py +++ b/python/vdsim_rl/__init__.py @@ -6,7 +6,7 @@ rows in place. Python never touches per-tick data. from vdsim_rl import EnvConfig, VDSimVecEnv - env = VDSimVecEnv(64, EnvConfig()) + env = VDSimVecEnv(64, EnvConfig(vehicle="generic_sedan", tire="generic_pacejka")) obs, info = env.reset(seed=0) obs, rew, term, trunc, info = env.step(env.action_space.sample()) @@ -20,6 +20,11 @@ from __future__ import annotations import dataclasses +import hashlib +import json +import os +import warnings +from pathlib import Path from typing import Callable, Dict, List, Optional, Sequence, Tuple import numpy as np @@ -30,7 +35,7 @@ __all__ = [ "EnvConfig", "VDSimVecEnv", "VDSimEnv", "make_sb3_vec_env", - "expand_obs_fields", "TERM_NAMES", "default_reward", + "expand_obs_fields", "TERM_NAMES", "default_reward", "load_vehicle_preset", ] # Per-wheel fields in core/src/vec_session.cpp; a bare name expands to 4 columns. @@ -68,10 +73,242 @@ def expand_obs_fields(fields: Sequence[str]) -> List[str]: return out +#: Keys a vehicle preset YAML may carry that VehicleParams does not bind, each +#: with the consumer that reads it. Anything else unbound is an error. +VEHICLE_SIDECAR_KEYS = frozenset({ + "tire_yaml", # vdsim_plant._load_tire_setup_for_vehicle, load_vehicle_preset + "body_lwh_m", # trace 0.3 geometry (vdsim_render3d) + "wheel_width_m", # trace 0.3 geometry (vdsim_render3d) +}) + +#: Keys core/src/params.cpp parses that the bindings expose under another shape, +#: so they are not attribute names of the bound struct. +VEHICLE_PARSED_UNBOUND = frozenset({ + "inertia_diag", # parsed as Vector3, bound as ixx / iyy / izz + "powertrain", # nested block (parse_powertrain), not bound +}) +TIRE_PARSED_UNBOUND = frozenset({ + "belt", # nested block, not bound +}) + +#: Keys a preset must state. VehicleParams/TireParams.from_yaml keep the C++ +#: generic default for a missing key, so a preset that omits one of these would +#: silently train a different car. +VEHICLE_REQUIRED_KEYS = ("mass", "wheelbase", "cg_to_front", "cg_to_rear", + "track_front", "track_rear", "cg_height", + "wheel_radius_nominal") +TIRE_REQUIRED_KEYS = ("mu_nominal", "Fz_nominal") + +BUILTIN_WARNING = "vehicle=None: C++ built-in generic parameters" + + +def _bound_fields(obj) -> List[str]: + """Data members a pybind11 params struct exposes (methods excluded).""" + return sorted(n for n in dir(obj) + if not n.startswith("_") and not callable(getattr(obj, n))) + + +def _check_keys(raw: dict, proto, extra, required, where: str) -> None: + """Reject keys the C++ parser would ignore and required keys that are missing. + + The allowed set is read from the bound struct itself (the YAML schema rule + in core/src/params.cpp maps top-level keys 1:1 to member names), so this is + not a second copy of the parser's key list; ``extra`` holds only the + exceptions (sidecar keys and keys bound under another shape). Nested maps + (e.g. ``lugre``) are checked against the nested struct the same way. + """ + allowed = set(_bound_fields(proto)) | set(extra) + unknown = sorted(set(raw) - allowed) + if unknown: + raise ValueError(f"{where}: keys the parser does not know: {unknown}") + missing = [k for k in required if k not in raw] + if missing: + raise ValueError(f"{where}: required keys missing: {missing}") + for key, val in raw.items(): + if isinstance(val, dict) and key not in extra: + sub_unknown = sorted(set(val) - set(_bound_fields(getattr(proto, key)))) + if sub_unknown: + raise ValueError(f"{where}: keys under '{key}' the parser does " + f"not know: {sub_unknown}") + + +def _digest_value(v): + if isinstance(v, (bool, int, float, str)) or v is None: + return v + if isinstance(v, (list, tuple)): + return [_digest_value(x) for x in v] + if isinstance(v, np.ndarray): + return v.tolist() + if hasattr(type(v), "__members__"): # pybind11 enum + return str(v) + fields = _bound_fields(v) + if fields: + return {f: _digest_value(getattr(v, f)) for f in fields} + return repr(v) + + +def _params_hash(vp, tp, tir_file: Optional[Path]) -> str: + """sha256 over the parameters as the core receives them (+ the .tir bytes).""" + blob = {"vehicle": _digest_value(vp), "tire": _digest_value(tp), + "tir_sha256": (hashlib.sha256(tir_file.read_bytes()).hexdigest() + if tir_file is not None else None)} + text = json.dumps(blob, sort_keys=True, separators=(",", ":"), default=repr) + return hashlib.sha256(text.encode()).hexdigest() + + +def _read_yaml(path: Path) -> dict: + import yaml + raw = yaml.safe_load(path.read_text()) or {} + if not isinstance(raw, dict): + raise ValueError(f"{path}: top level must be a mapping") + return raw + + +#: Environment variable naming a preset root outside the repository, for cars +#: whose parameters must not be published. Same layout as ``configs/``: +#: ``/vehicles/.yaml``, ``/parts/tire/.yaml``. +PRIVATE_ROOT_ENV = "VDSIM_PRIVATE_CONFIGS" + + +def _private_root() -> Optional[Path]: + """``$VDSIM_PRIVATE_CONFIGS`` as a directory, or None when it is unset.""" + raw = os.environ.get(PRIVATE_ROOT_ENV, "").strip() + if not raw: + return None + root = Path(raw).expanduser() + if not root.is_dir(): + raise FileNotFoundError(f"{PRIVATE_ROOT_ENV}={raw!r} is not a directory") + return root + + +def _preset_source(path: Path) -> str: + """``"public"`` for a file under the repo's ``configs/``, else ``"private"``.""" + from vdsim_plant import _conf_root + try: + path.resolve().relative_to(_conf_root().resolve()) + return "public" + except ValueError: + return "private" + + +def _resolve_preset_path(rel: str, explicit: Optional[str], what: str) -> Path: + """Locate one preset file, in a fixed order. + + ``_file`` (an absolute path) wins over ``$VDSIM_PRIVATE_CONFIGS/``, + which wins over the repository's ``configs/``. ``rel`` is the path + inside a preset root (``vehicles/x.yaml``, ``parts/tire/x.yaml``). A + relative ``_file`` is refused: it would resolve against the working + directory, so one declaration would load different cars depending on where + the run was started. + """ + from vdsim_plant import _conf_root + + if explicit: + p = Path(explicit).expanduser() + if not p.is_absolute(): + raise ValueError(f"{what}_file must be an absolute path: {explicit!r}") + if not p.is_file(): + raise FileNotFoundError(f"{what}_file not found: {p}") + return p + roots = [r for r in (_private_root(), _conf_root()) if r is not None] + for root in roots: + if (root / rel).is_file(): + return root / rel + raise FileNotFoundError(f"{what} preset '{rel}' not found under " + + ", ".join(str(r) for r in roots)) + + +def load_vehicle_preset(vehicle: Optional[str], tire: Optional[str], *, + vehicle_file: Optional[str] = None, + tire_file: Optional[str] = None): + """Build VehicleParams/TireParams from preset names; reads files, writes none. + + ``vehicle`` is a stem under ``configs/vehicles/``, ``tire`` a stem under + ``configs/parts/tire/``; ``vehicle_file`` / ``tire_file`` give an absolute + path instead (search order in ``_resolve_preset_path``). ``tire=None`` with + a vehicle takes that vehicle's ``tire_yaml``, resolved against the vehicle + file's own root first, so a private car can carry a private tyre. + ``vehicle=None`` keeps the C++ built-in generic car and warns. Unknown keys + or missing required keys raise ``ValueError``. + + Returns ``(VehicleParams, TireParams, provenance)``; provenance is + ``{"vehicle", "tire", "param_hash", "source"}``. It carries names only -- + no path, so a training log cannot leak a private directory layout. + """ + from vdsim_plant import _resolve_tir_path + + used: List[Path] = [] + if vehicle is None: + if vehicle_file: + raise ValueError("vehicle_file needs vehicle= as the recorded name") + warnings.warn(BUILTIN_WARNING, UserWarning, stacklevel=3) + vp = vdsim.VehicleParams() + vp_path = None + tire_rel = None + else: + vp_path = _resolve_preset_path(f"vehicles/{Path(vehicle).stem}.yaml", + vehicle_file, "vehicle") + used.append(vp_path) + raw = _read_yaml(vp_path) + _check_keys(raw, vdsim.VehicleParams(), + VEHICLE_SIDECAR_KEYS | VEHICLE_PARSED_UNBOUND, + VEHICLE_REQUIRED_KEYS, str(vp_path)) + vp = vdsim.VehicleParams.from_yaml(str(vp_path)) + tire_rel = raw.get("tire_yaml") + + if tire is not None: + tp_path = _resolve_preset_path(f"parts/tire/{Path(tire).stem}.yaml", + tire_file, "tire") + elif tire_rel: + sibling = vp_path.parent.parent / tire_rel + tp_path = (sibling if sibling.is_file() + else _resolve_preset_path(tire_rel, None, "tire")) + elif vehicle is not None: + raise ValueError(f"vehicle '{vehicle}' names no tire_yaml; pass tire=") + else: + if tire_file: + raise ValueError("tire_file needs tire= as the recorded name") + tp_path = None + + tir_file = None + if tp_path is None: + tp = vdsim.TireParams() + else: + used.append(tp_path) + raw_tp = _read_yaml(tp_path) + if "schema" in raw_tp and "body" in raw_tp: + raise ValueError(f"{tp_path}: catalog part (schema/body); vdsim_rl reads " + f"flat TireParams files only") + _check_keys(raw_tp, vdsim.TireParams(), TIRE_PARSED_UNBOUND, + TIRE_REQUIRED_KEYS, str(tp_path)) + tp = vdsim.TireParams.from_yaml(str(tp_path)) + if tp.tir_path: + tp.tir_path = _resolve_tir_path(tp_path, tp.tir_path) + tir_file = Path(tp.tir_path) + if not tir_file.is_file(): + raise FileNotFoundError(f"{tp_path}: tir_path not found: {tir_file}") + + provenance = { + "vehicle": None if vehicle is None else Path(vehicle).stem, + "tire": None if tp_path is None else tp_path.stem, + "param_hash": _params_hash(vp, tp, tir_file), + "source": ("private" if any(_preset_source(q) == "private" for q in used) + else "public"), + } + return vp, tp, provenance + + @dataclasses.dataclass class EnvConfig: """Everything the env needs; ``from_yaml`` loads the same keys from a file.""" level: str = "L2" # plant fidelity (L1 bicycle .. L3 14-DOF) + # car: preset stems (configs/vehicles, configs/parts/tire); None = C++ built-in + vehicle: Optional[str] = None + tire: Optional[str] = None + # absolute paths that win over both preset roots, for a car kept out of + # the repository; the stem above is still what gets recorded as its name + vehicle_file: Optional[str] = None + tire_file: Optional[str] = None dt: float = 0.005 # physics tick [s] action_repeat: int = 4 # control interval = dt * action_repeat max_steer: float = 0.5 # action scale, wheel steer [rad] @@ -154,8 +391,13 @@ def __init__(self, num_envs: int, cfg: EnvConfig, solver.max_substep_dt = cfg.max_substep_dt solver.max_substeps = cfg.max_substeps + # Parsed once here in the parent; every env shares these values and no + # file is written, so the Q10-c shared-cache race has no path in. + vp, tp, self.provenance = load_vehicle_preset( + cfg.vehicle, cfg.tire, + vehicle_file=cfg.vehicle_file, tire_file=cfg.tire_file) self.vs = vdsim.make_vec_session( - num_envs, vdsim.VehicleParams(), vdsim.TireParams(), + num_envs, vp, tp, level=cfg.level, nominal_dt=cfg.dt, mu=cfg.mu, solver=solver, threads=cfg.threads) @@ -309,6 +551,7 @@ def __init__(self, num_envs: int, cfg: Optional[EnvConfig] = None, self.single_observation_space, num_envs) self.action_space = gym.vector.utils.batch_space( self.single_action_space, num_envs) + self.metadata = {**type(self).metadata, "vdsim": dict(self.core.provenance)} self.closed = False @property @@ -319,7 +562,7 @@ def reset(self, *, seed: Optional[int] = None, options=None): if seed is not None: self.core.seed(seed) obs = self.core.reset_all().copy() - return obs, {} + return obs, dict(self.core.provenance) def step(self, actions): obs, rew, terminated, truncated, codes = self.core.step(actions) @@ -357,6 +600,7 @@ def __init__(self, cfg: Optional[EnvConfig] = None, reward_fn: Optional[Callable] = None, seed: Optional[int] = None): self.core = _Core(1, cfg or EnvConfig(), reward_fn, seed) self.observation_space, self.action_space = _spaces(self.core.vs.obs_dim) + self.metadata = {**type(self).metadata, "vdsim": dict(self.core.provenance)} @property def obs_columns(self) -> List[str]: @@ -365,7 +609,7 @@ def obs_columns(self) -> List[str]: def reset(self, *, seed: Optional[int] = None, options=None): if seed is not None: self.core.seed(seed) - return self.core.reset_all()[0].copy(), {} + return self.core.reset_all()[0].copy(), dict(self.core.provenance) def step(self, action): a = np.asarray(action, dtype=np.float32).reshape(1, 2) @@ -389,6 +633,8 @@ def __init__(self): self.core = _Core(num_envs, cfg or EnvConfig(), reward_fn, seed) obs_space, act_space = _spaces(self.core.vs.obs_dim) super().__init__(num_envs, obs_space, act_space) + self.metadata = {**getattr(self, "metadata", {}), + "vdsim": dict(self.core.provenance)} self._actions = np.zeros((num_envs, 2), dtype=np.float32) def reset(self): diff --git a/tests/rl/gate_g1_substep_accuracy.py b/tests/rl/gate_g1_substep_accuracy.py index 57856df5..d11685ac 100644 --- a/tests/rl/gate_g1_substep_accuracy.py +++ b/tests/rl/gate_g1_substep_accuracy.py @@ -1,25 +1,98 @@ """G1: does relaxing max_substep_dt (the RL preset) cost accuracy? -Reference = the same plant integrated with max_substep_dt = 0.1 ms (100x finer -than the shipped default), everything else identical. Error metrics are taken -against that reference on two L3 manoeuvres, and throughput is measured on the -same box so the trade is visible in one table. +Reference = the same plant integrated with a much finer max_substep_dt +(``--ref-substep``, default 1 us), everything else identical: same outer tick +DT, same zero-order-hold input sampling, same manoeuvres. Error metrics are +taken against that reference on two manoeuvres, and throughput is measured on +the same box so the trade is visible in one table. + +Error definition (unchanged since 2026-09-17, so old and new tables compare): + + max_rel_pct(h) = 100 * max_t |x_h(t) - x_ref(t)| / max_t |x_ref(t)| + +Convergence order between two grid points h_a < h_b (effective step sizes): + + p = log(E(h_b) / E(h_a)) / log(h_b / h_a) (= log10 ratio for a decade) + +The reference must really be integrated at ``--ref-substep``. solver_substeps() +silently caps the substep count at SolverParams.max_substeps (params.cpp), so +``--max-substeps`` must cover DT / min(step); the script refuses to start +otherwise and, after the run, requires vdsim.solver_substep_clamp_count() == 0. +A run with a non-zero count is written with ``"discarded": true`` and exits 3. + +Every grid row is also compared against the 0.1 ms run (``vs_0p1ms_pct``), the +reference used before 2026-09-22, so the old table can be printed next to the +new one without re-running anything. + +The car is part of the measurement: ``--vehicle``/``--tire`` take the same +preset stems as ``EnvConfig`` and go through ``vdsim_rl.load_vehicle_preset``, +so the printed ``param_hash`` is the one an env built from that YAML reports. +Without ``--vehicle`` the C++ built-in generic car is used. + + python3 tests/rl/gate_g1_substep_accuracy.py --vehicle generic_sedan \\ + --tire generic_pacejka --level L2 --out /tmp/g1_generic_L2.json + +A preset that lives outside the repository is measured the same way: point +$VDSIM_PRIVATE_CONFIGS at its directory and pass its stems. + +Python paths: VDSIM_BUILD_PY (compiled module dir) and VDSIM_PY (repo python/) +default to the in-repo build tree. """ -import json, sys, time -sys.path.insert(0, "/home/ailab-12/git/VDSim/build/python") +import argparse, json, math, os, sys, time +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, os.environ.get("VDSIM_BUILD_PY", str(_REPO / "build" / "python"))) +sys.path.insert(0, os.environ.get("VDSIM_PY", str(_REPO / "python"))) import numpy as np import vdsim -DT = 0.005 # outer tick [s] (the RL control tick) -LEVEL = "L3" -REF_SUBSTEP = 1e-4 +DT = 0.005 # outer tick [s] (the RL control tick); never varied here +OLD_REF_SUBSTEP = 1e-4 + +ap = argparse.ArgumentParser() +ap.add_argument("--vehicle", default=None, help="configs/vehicles/.yaml") +ap.add_argument("--tire", default=None, help="configs/parts/tire/.yaml") +ap.add_argument("--level", default="L3") +ap.add_argument("--ref-substep", type=float, default=1e-6, + help="reference max_substep_dt [s] (default 1 us)") +ap.add_argument("--grid", default="1e-5,1e-4,5e-4,1e-3,2.5e-3,5e-3", + help="comma list of max_substep_dt [s] to evaluate") +ap.add_argument("--max-substeps", type=int, default=6000, + help="SolverParams.max_substeps; must cover DT/min(step)") +ap.add_argument("--throughput-min-substep", type=float, default=5e-4, + help="skip the 64-env throughput bench below this step [s]") +ap.add_argument("--out", default="/tmp/g1.json") +ARGS = ap.parse_args() +LEVEL = ARGS.level +REF_SUBSTEP = ARGS.ref_substep +CANDIDATES = sorted(float(x) for x in ARGS.grid.split(",")) + + +def substeps(h): + """Substep count solver_substeps() uses for this h (std::ceil(DT/h)).""" + return max(1, math.ceil(DT / h)) + + +_need = substeps(min(CANDIDATES + [REF_SUBSTEP])) +if ARGS.max_substeps < _need: + sys.exit(f"--max-substeps {ARGS.max_substeps} < {_need} needed for " + f"h={min(CANDIDATES + [REF_SUBSTEP])} at DT={DT}: the solver would " + f"clamp and the reference would not be what it claims to be") + +if ARGS.vehicle is None and ARGS.tire is None: + VP, TP = vdsim.VehicleParams(), vdsim.TireParams() + PROV = {"vehicle": None, "tire": None, "param_hash": None} +else: + from vdsim_rl import load_vehicle_preset + VP, TP, PROV = load_vehicle_preset(ARGS.vehicle, ARGS.tire) + def session(max_substep_dt, n=1, threads=1): sp = vdsim.SolverParams() sp.max_substep_dt = max_substep_dt - sp.max_substeps = 2000 # never clamp: we are measuring the step size - return vdsim.make_vec_session(n, vdsim.VehicleParams(), vdsim.TireParams(), - level=LEVEL, nominal_dt=DT, mu=1.0, + sp.max_substeps = ARGS.max_substeps + return vdsim.make_vec_session(n, VP, TP, level=LEVEL, nominal_dt=DT, mu=1.0, solver=sp, threads=threads) def step_steer(max_substep_dt, v0=20.0, steer=0.05, t_end=3.0, t_step=0.5): @@ -58,47 +131,82 @@ def throughput(max_substep_dt, envs=64, threads=22, steps=2000): t0 = time.perf_counter(); v.tick(DT, steps); el = time.perf_counter() - t0 return envs * steps / el -CANDIDATES = [5e-4, 1e-3, 2.5e-3, 5e-3] # 1e-3 is the shipped default +def rel_pct(g, r): + return float(100.0 * np.abs(g - r).max() / max(np.abs(r).max(), 1e-12)) + SIGNALS = {"step_steer": ["yaw_rate [rad/s]", "ay [m/s^2]", "roll [rad]", "vy [m/s]"], "brake": ["vx [m/s]", "x [m]", "pitch [rad]", "Fz_FL [N]"]} +KEYS = [f"{m}:{n}" for m, ns in SIGNALS.items() for n in ns] +print(f"level={LEVEL} vehicle={PROV['vehicle']} tire={PROV['tire']} " + f"param_hash={PROV['param_hash']} ref_substep={REF_SUBSTEP:g} s " + f"max_substeps={ARGS.max_substeps}") +vdsim.reset_solver_substep_clamp_count() +t0 = time.perf_counter() ref = {"step_steer": step_steer(REF_SUBSTEP), "brake": brake(REF_SUBSTEP)} +ref_wall = time.perf_counter() - t0 +print(f"reference ({substeps(REF_SUBSTEP)} sub/tick) wall {ref_wall:.2f} s") + +runs = {h: {"step_steer": step_steer(h), "brake": brake(h)} for h in CANDIDATES} +old = runs.get(OLD_REF_SUBSTEP) rows = [] for h in CANDIDATES: - got = {"step_steer": step_steer(h), "brake": brake(h)} - rec = {"max_substep_dt": h, "substeps_per_tick": max(1, int(round(DT / h)))} + got = runs[h] + n_sub = substeps(h) + rec = {"max_substep_dt": h, "substeps_per_tick": n_sub, "h_eff": DT / n_sub} for man, names in SIGNALS.items(): r, g = ref[man], got[man] for j, nm in enumerate(names): err = g[:, j] - r[:, j] - scale = max(np.abs(r[:, j]).max(), 1e-12) rec[f"{man}:{nm}"] = { "rmse": float(np.sqrt(np.mean(err ** 2))), "max_abs": float(np.abs(err).max()), - "max_rel_pct": float(100.0 * np.abs(err).max() / scale), + "max_rel_pct": rel_pct(g[:, j], r[:, j]), "ref_peak": float(np.abs(r[:, j]).max()), + "vs_0p1ms_pct": (rel_pct(g[:, j], old[man][:, j]) + if old is not None else None), } - rec["steps_per_s_64env_22thr"] = throughput(h) + rec["steps_per_s_64env_22thr"] = (throughput(h) + if h >= ARGS.throughput_min_substep else None) rows.append(rec) - worst = max(v["max_rel_pct"] for k, v in rec.items() if isinstance(v, dict)) - print(f"max_substep_dt={h*1000:5.2f} ms ({rec['substeps_per_tick']:2d} sub/tick) " - f"worst max-rel error {worst:6.3f} % " - f"{rec['steps_per_s_64env_22thr']:11,.0f} steps/s") +clamp_count = int(vdsim.solver_substep_clamp_count()) +print(f"solver_substep_clamp_count() = {clamp_count}") + +# convergence order for every adjacent pair of the grid (h_a < h_b) +orders = [] +for a, b in zip(rows[:-1], rows[1:]): + pair = {"h_a": a["h_eff"], "h_b": b["h_eff"]} + for k in KEYS: + ea, eb = a[k]["max_rel_pct"], b[k]["max_rel_pct"] + pair[k] = (math.log(eb / ea) / math.log(b["h_eff"] / a["h_eff"]) + if ea > 0 and eb > 0 else None) + orders.append(pair) + +print() +print(f"{'max-rel error vs ref [%]':28s} " + + " ".join(f"{h*1000:>10.3g}ms" for h in CANDIDATES)) +for k in KEYS: + print(f"{k:28s} " + " ".join(f"{r[k]['max_rel_pct']:11.4g}%" for r in rows)) +print() +print(f"{'order p (adjacent pairs)':28s} " + + " ".join(f"{o['h_a']*1000:>5.3g}/{o['h_b']*1000:<5.3g}" for o in orders)) +for k in KEYS: + print(f"{k:28s} " + " ".join( + f"{o[k]:11.3f}" if o[k] is not None else f"{'n/a':>11s}" for o in orders)) print() -print(f"{'signal':28s} " + " ".join(f"{h*1000:>10.2f}ms" for h in CANDIDATES)) -for man, names in SIGNALS.items(): - for nm in names: - key = f"{man}:{nm}" - line = f"{key:28s} " + " ".join( - f"{r[key]['max_rel_pct']:11.4f}%" for r in rows) - print(line) - -ref_stop = ref["brake"][-1, 1] -print(f"\nreference stopping distance {ref_stop:.4f} m; candidates: " + - ", ".join(f"{h*1000:.2f}ms -> {brake(h)[-1,1]:.4f} m" for h in CANDIDATES)) +print("throughput [steps/s, 64 env x 22 thr]: " + ", ".join( + f"{r['max_substep_dt']*1000:g}ms -> " + + (f"{r['steps_per_s_64env_22thr']:,.0f}" if r['steps_per_s_64env_22thr'] else "skipped") + for r in rows)) +discarded = clamp_count != 0 json.dump({"reference_max_substep_dt": REF_SUBSTEP, "outer_dt": DT, - "level": LEVEL, "rows": rows}, - open("/tmp/g1.json", "w"), indent=1) + "max_substeps": ARGS.max_substeps, "clamp_count": clamp_count, + "discarded": discarded, "reference_wall_s": ref_wall, + "level": LEVEL, "provenance": PROV, "rows": rows, "orders": orders}, + open(ARGS.out, "w"), indent=1) +if discarded: + print("G1 DISCARDED: substep clamp fired; the reference is not the claimed one") + sys.exit(3) print("G1 DONE") diff --git a/tests/rl/smoke_sb3_ppo.py b/tests/rl/smoke_sb3_ppo.py new file mode 100644 index 00000000..1ecdc304 --- /dev/null +++ b/tests/rl/smoke_sb3_ppo.py @@ -0,0 +1,26 @@ +# R2: stable-baselines3 PPO smoke on make_sb3_vec_env (needs torch + SB3). +# Not a ctest target: CI does not install torch. The torch-free checks are in +# test_rl_env.py (ctest rl_env). Paths are repo-relative; VDSIM_BUILD_PY +# overrides the built module dir. +import os, sys, time +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, os.environ.get("VDSIM_BUILD_PY", str(REPO / "build" / "python"))) +sys.path.insert(0, str(REPO / "python")) +import numpy as np +from stable_baselines3 import PPO +from vdsim_rl import EnvConfig, make_sb3_vec_env + +cfg = EnvConfig.from_yaml(str(REPO / "configs/rl/default_env.yaml")) +venv = make_sb3_vec_env(8, cfg, seed=11) +t0 = time.perf_counter() +model = PPO("MlpPolicy", venv, n_steps=64, batch_size=64, n_epochs=2, verbose=0, + device="cpu") +model.learn(total_timesteps=2048) +el = time.perf_counter() - t0 +obs = venv.reset() +act, _ = model.predict(obs, deterministic=True) +assert np.asarray(act).shape == (8, 2) +print(f"SB3 PPO learn(2048) on 8 envs: {el:.1f} s, predict -> {np.asarray(act).shape}") +venv.close() +print("SB3 SMOKE PASSED") diff --git a/tests/rl/test_rl_env.py b/tests/rl/test_rl_env.py index 8c90345c..3eb66449 100644 --- a/tests/rl/test_rl_env.py +++ b/tests/rl/test_rl_env.py @@ -1,16 +1,247 @@ -# R2/R3/R4 verification: gym adapter, termination codes, flat obs buffer. -import json, sys, time -sys.path.insert(0, "/home/ailab-12/git/VDSim/build/python") -sys.path.insert(0, "/home/ailab-12/git/VDSim/python") +# R2/R3/R4 verification: gym adapter, termination codes, flat obs buffer, +# and (Q23) the vehicle preset input. +# Paths are repo-relative; VDSIM_BUILD_PY overrides the built module dir. +import json, os, sys, time, warnings +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, os.environ.get("VDSIM_BUILD_PY", str(REPO / "build" / "python"))) +sys.path.insert(0, str(REPO / "python")) import numpy as np +import yaml import vdsim -from vdsim_rl import (EnvConfig, VDSimVecEnv, VDSimEnv, make_sb3_vec_env, - expand_obs_fields, TERM_NAMES) +import vdsim_rl +from vdsim_rl import (EnvConfig, VDSimVecEnv, VDSimEnv, + expand_obs_fields, TERM_NAMES, load_vehicle_preset) res = {} +G = 9.81 +GENERIC = yaml.safe_load((REPO / "configs/vehicles/generic_sedan.yaml").read_text()) + +# A synthetic probe car, injected the way a preset with non-public specs is +# injected ($VDSIM_PRIVATE_CONFIGS -> vehicles/.yaml). Every shipped car +# carries the C++ default values, so "the YAML reached the plant" checked +# against one of them would be vacuous; Q23-1 asserts the fixture actually +# differs. These numbers are invented for this test and describe no vehicle. +import contextlib, shutil, tempfile +PROBE_NAME = "probe_car" +PROBE = {**GENERIC, "mass": 1750.0, "mass_sprung": 1580.0, "wheelbase": 2.6, + "cg_to_front": 1.15, "cg_to_rear": 1.45, "cg_height": 0.58, + "track_front": 1.58, "track_rear": 1.58, "wheel_radius_nominal": 0.33} +PROBE_ROOT = Path(tempfile.mkdtemp(prefix="vdsim_probe_")) +(PROBE_ROOT / "vehicles").mkdir() +(PROBE_ROOT / "vehicles" / f"{PROBE_NAME}.yaml").write_text(yaml.safe_dump(PROBE)) + + +@contextlib.contextmanager +def probe_root(): + """Expose the probe car only where it is used. + + A private root left set would also resolve the shipped tyre privately and + flip ``info["source"]`` for the public declarations checked in Q23-2. + """ + os.environ[vdsim_rl.PRIVATE_ROOT_ENV] = str(PROBE_ROOT) + try: + yield + finally: + os.environ.pop(vdsim_rl.PRIVATE_ROOT_ENV, None) + + +def plant_weight_kg(cfg, n=2): + """Total vertical tyre load / g of the plant the env actually built [kg]. + + Coasting straight at 10 m/s for 1 s; reads the per-wheel ``fz`` columns, + so it measures the core's mass, not the Python-side params object. + """ + c = EnvConfig(**{**cfg.__dict__, "level": "L2", + "obs_fields": ["vx", "fz"], "speed_range": (10.0, 10.0), + "lateral_range": 0.0, "yaw_range": 0.0, + "time_limit_s": -1.0, "min_speed": -1.0}) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + e = VDSimVecEnv(n, c, seed=0, + reward_fn=lambda o, *_: np.zeros(len(o), np.float32)) + e.reset(seed=0) + for _ in range(50): + o, *_ = e.step(np.zeros((n, 2), dtype=np.float32)) + cols = [e.core.col[f"fz.{w}"] for w in range(4)] + return o[:, cols].sum(axis=1) / G + + +# ---- Q23-1: a named preset -> the plant carries that YAML's car ---- +with probe_root(): + vp, tp, prov = load_vehicle_preset(PROBE_NAME, "generic_pacejka") + for key in ("mass", "wheelbase", "cg_to_front", "cg_to_rear", "cg_height", + "track_front", "wheel_radius_nominal"): + assert abs(PROBE[key] - GENERIC[key]) > 1e-9, f"{key} cannot discriminate" + assert abs(getattr(vp, key) - PROBE[key]) < 1e-9, key + assert prov["source"] == "private" and prov["vehicle"] == PROBE_NAME, prov + w = plant_weight_kg(EnvConfig(vehicle=PROBE_NAME, tire="generic_pacejka")) +print(f"Q23-1 probe plant weight {w.round(1)} kg vs YAML mass {PROBE['mass']}") +assert np.all(np.abs(w / PROBE["mass"] - 1.0) < 0.01), w +res["q23_probe_weight_kg"] = w.tolist() + +# ---- Q23-2 (Q23-f): both shipped RL declarations select the public generic +# car, name it, and do not warn (PO decision 78) ---- +for name in ("default_env.yaml", "fast_env.yaml"): + c = EnvConfig.from_yaml(str(REPO / "configs/rl" / name)) + assert (c.vehicle, c.tire) == ("generic_sedan", "generic_pacejka"), \ + (name, c.vehicle, c.tire) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + e = VDSimVecEnv(2, c, seed=0) + assert not [x for x in caught + if vdsim_rl.BUILTIN_WARNING in str(x.message)], (name, caught) + _, info = e.reset(seed=0) + assert info["vehicle"] == "generic_sedan", info + assert info["tire"] == "generic_pacejka" and info["source"] == "public", info + assert len(info["param_hash"]) == 64 and e.metadata["vdsim"] == info + w = plant_weight_kg(EnvConfig(**{**c.__dict__, "mass_scale_range": (1.0, 1.0)})) + assert np.all(np.abs(w / GENERIC["mass"] - 1.0) < 0.01), (name, w) + print(f"Q23-2 {name}: info {info['vehicle']}/{info['tire']} " + f"({info['source']}) hash {info['param_hash'][:12]}, " + f"weight {w.round(1)} kg") +res["q23_yaml_info"] = info +assert EnvConfig.from_yaml(str(REPO / "configs/rl/fast_env.yaml")).max_substep_dt \ + == 0.0025 + +# ---- Q23-3: vehicle=None still works but says so ---- +with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + e = VDSimVecEnv(1, EnvConfig(), seed=0) +msgs = [str(x.message) for x in caught if issubclass(x.category, UserWarning)] +assert vdsim_rl.BUILTIN_WARNING in msgs, msgs +assert e.reset(seed=0)[1]["vehicle"] is None +print(f"Q23-3 vehicle=None warns: {msgs}") + +# ---- Q23-4: domain randomization scales the loaded preset, not the default ---- +with probe_root(): + w = plant_weight_kg(EnvConfig(vehicle=PROBE_NAME, tire="generic_pacejka", + mass_scale_range=(1.1, 1.1))) +print(f"Q23-4 mass_scale 1.1 -> {w.round(1)} kg (expect {1.1 * PROBE['mass']:.1f})") +assert np.all(np.abs(w / (1.1 * PROBE["mass"]) - 1.0) < 0.01), w +assert np.all(np.abs(w / (1.1 * GENERIC["mass"]) - 1.0) > 0.05), w + +# ---- Q23-5: loading writes nothing ---- +def tree(root): + return {str(q): (q.stat().st_size, q.stat().st_mtime_ns) + for q in Path(root).rglob("*") if q.is_file()} +import builtins +from vdsim_plant import _conf_root +before = (tree(_conf_root()), tree(os.getcwd()) if Path.cwd() != REPO else {}) +opened_for_write = [] +_real_open = builtins.open +def _spy_open(file, mode="r", *a, **k): + if any(m in mode for m in "wax+"): + opened_for_write.append(str(file)) + return _real_open(file, mode, *a, **k) +builtins.open = _spy_open +try: + e = VDSimVecEnv(8, EnvConfig.from_yaml(str(REPO / "configs/rl/default_env.yaml")), seed=1) + e.reset(seed=1) +finally: + builtins.open = _real_open +after = (tree(_conf_root()), tree(os.getcwd()) if Path.cwd() != REPO else {}) +assert not opened_for_write, opened_for_write +assert before == after, "files changed while loading the preset" +print(f"Q23-5 files opened for write: {len(opened_for_write)}, " + f"config tree unchanged ({len(before[0])} files)") + +# ---- Q23-6: a preset key the parser ignores, or a missing required key, raises ---- +import tempfile +from vdsim_rl import _check_keys, VEHICLE_SIDECAR_KEYS, VEHICLE_PARSED_UNBOUND, \ + VEHICLE_REQUIRED_KEYS +extra = VEHICLE_SIDECAR_KEYS | VEHICLE_PARSED_UNBOUND +for bad, want in (({**PROBE, "masss": 1.0}, "does not know"), + ({k: v for k, v in PROBE.items() if k != "cg_height"}, "missing")): + try: + _check_keys(bad, vdsim.VehicleParams(), extra, VEHICLE_REQUIRED_KEYS, "probe") + except ValueError as exc: + assert want in str(exc), exc + print(f"Q23-6 rejected: {exc}") + else: + raise AssertionError(f"not rejected: {want}") +# flat tyre presets load (no false rejection); catalog parts (schema/body) are +# refused -- read flat, every coefficient would silently be the C++ default. +for tyre in sorted((REPO / "configs/parts/tire").glob("*.yaml")): + catalog = "schema" in (yaml.safe_load(tyre.read_text()) or {}) + try: + with probe_root(): + load_vehicle_preset(PROBE_NAME, tyre.stem) + assert not catalog, f"catalog part {tyre.name} was accepted" + except ValueError as exc: + assert catalog and "catalog part" in str(exc), exc + print(f"Q23-6 tyre {tyre.stem:20s} {'refused (catalog part)' if catalog else 'loads'}") +shutil.rmtree(PROBE_ROOT) +# ---- Q23-f1: generic_sedan/generic_pacejka == the C++ built-in defaults ---- +# Field-by-field, so a change to either the YAML or the C++ default that +# is not mirrored in the other one fails here instead of silently +# training a different car than fast_env.yaml's G1 table (a) describes. +def bound_fields(obj): + return sorted(n for n in dir(obj) + if not n.startswith("_") and not callable(getattr(obj, n))) + + +vp_g, tp_g, prov_g = load_vehicle_preset("generic_sedan", "generic_pacejka") +drift = [] +for proto, got in ((vdsim.VehicleParams(), vp_g), (vdsim.TireParams(), tp_g)): + for f in bound_fields(proto): + a = vdsim_rl._digest_value(getattr(proto, f)) + b = vdsim_rl._digest_value(getattr(got, f)) + if a != b: + drift.append((type(proto).__name__, f, a, b)) +assert not drift, f"generic preset drifted from the C++ defaults: {drift}" +with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + _, _, prov_builtin = load_vehicle_preset(None, None) +assert prov_g["param_hash"] == prov_builtin["param_hash"], (prov_g, prov_builtin) +assert set(prov_g) == {"vehicle", "tire", "param_hash", "source"}, prov_g +assert prov_g["source"] == "public", prov_g +assert "/" not in prov_g["vehicle"] + prov_g["tire"], prov_g +res["q23f_generic_param_hash"] = prov_g["param_hash"] +print(f"Q23-f1 generic_sedan+generic_pacejka == C++ defaults on " + f"{len(bound_fields(vdsim.VehicleParams())) + len(bound_fields(vdsim.TireParams()))} " + f"fields, same param_hash {prov_g['param_hash'][:12]}") + +# ---- Q23-f2: search order vehicle_file -> $VDSIM_PRIVATE_CONFIGS -> repo ---- +import shutil +_priv = Path(tempfile.mkdtemp(prefix="vdsim_priv_")) +(_priv / "vehicles").mkdir() +(_priv / "parts" / "tire").mkdir(parents=True) +_shadow = {**GENERIC, "mass": 1750.0} +(_priv / "vehicles" / "generic_sedan.yaml").write_text(yaml.safe_dump(_shadow)) +shutil.copy(REPO / "configs/parts/tire/generic_pacejka.yaml", + _priv / "parts" / "tire" / "generic_pacejka.yaml") +_abs = Path(tempfile.mkdtemp(prefix="vdsim_abs_")) / "whatever.yaml" +_abs.write_text(yaml.safe_dump({**GENERIC, "mass": 1900.0})) +os.environ[vdsim_rl.PRIVATE_ROOT_ENV] = str(_priv) +try: + vp_p, _, prov_p = load_vehicle_preset("generic_sedan", "generic_pacejka") + assert vp_p.mass == 1750.0 and prov_p["source"] == "private", (vp_p.mass, prov_p) + # the private vehicle's own tire_yaml resolves inside the private root + _, _, prov_pt = load_vehicle_preset("generic_sedan", None) + assert prov_pt["source"] == "private" and prov_pt["tire"] == "generic_pacejka", prov_pt + # an absolute file wins over the private root, and only the stem is recorded + vp_f, _, prov_f = load_vehicle_preset("generic_sedan", "generic_pacejka", + vehicle_file=str(_abs)) + assert vp_f.mass == 1900.0 and prov_f["source"] == "private", (vp_f.mass, prov_f) + assert prov_f["vehicle"] == "generic_sedan", prov_f +finally: + os.environ.pop(vdsim_rl.PRIVATE_ROOT_ENV) +vp_r, _, prov_r = load_vehicle_preset("generic_sedan", "generic_pacejka") +assert vp_r.mass == GENERIC["mass"] and prov_r["source"] == "public", (vp_r.mass, prov_r) +try: + load_vehicle_preset("generic_sedan", "generic_pacejka", + vehicle_file="configs/vehicles/generic_sedan.yaml") +except ValueError as exc: + assert "absolute" in str(exc), exc +else: + raise AssertionError("a relative vehicle_file was accepted") +print("Q23-f2 search order: abs file 1900 kg > private root 1750 kg > repo " + f"{GENERIC['mass']:.0f} kg, relative vehicle_file refused") +print("Q23 vehicle preset checks passed") # ---- R4a: YAML declaration -> layout, and the buffer is written in place ---- -cfg = EnvConfig.from_yaml("/home/ailab-12/git/VDSim/configs/rl/default_env.yaml") +cfg = EnvConfig.from_yaml(str(REPO / "configs/rl/default_env.yaml")) env = VDSimVecEnv(8, cfg, seed=0) print("obs columns:", env.obs_columns) obs, _ = env.reset(seed=0) @@ -103,19 +334,8 @@ def force(kind): print(f"VDSimVecEnv 16 envs x 500 control steps: {sps:,.0f} physics ticks/s, " f"{n_done} auto-resets") -# ---- R2: SB3 PPO smoke ---- -from stable_baselines3 import PPO -venv = make_sb3_vec_env(8, cfg, seed=11) -t0 = time.perf_counter() -model = PPO("MlpPolicy", venv, n_steps=64, batch_size=64, n_epochs=2, verbose=0, - device="cpu") -model.learn(total_timesteps=2048) -el = time.perf_counter() - t0 -obs = venv.reset() -act, _ = model.predict(obs, deterministic=True) -res["sb3"] = {"seconds": el, "action_shape": list(np.asarray(act).shape)} -print(f"SB3 PPO learn(2048) on 8 envs: {el:.1f} s, predict -> {np.asarray(act).shape}") -venv.close() +# The SB3 PPO smoke (torch) lives in smoke_sb3_ppo.py, outside ctest: this +# file is the ctest target rl_env and needs gymnasium but not torch/SB3. json.dump(res, open("/tmp/rl_smoke.json", "w"), indent=1, default=str) print("ALL CHECKS PASSED")