English · 한국어
Experimental / pre-release — not for production. Evidence and limits: VALIDATION.md (v0.5.1+). Validated: analytic + ISO maneuvers + L1↔L3 self-consistency + pure-slip same-
.tircross-check (Chrono Pac02 ~0.8%) — not MF-Tyre product parity. NOT yet: full-vehicle commercial cross-val, real-vehicle data, production sign-off.
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).
Experimental / pre-release software — not for production use. What is verified, and what is not yet: see docs/VALIDATION.md.
Verification scope. VDSim's tire-force layer is cross-checked against an independent
implementation driven by the same .tir parameter file: 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.
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 that perception stacks lack.
Docs (theory + reports): https://onlyti.github.io/VDSim/ · all run modes (API / batch / FMI): docs/RUNNING.md · catalog & physics options: docs/CATALOG_AND_PHYSICS.md
Prerequisites: Python 3.8–3.12 (pre-built wheels for Linux x86_64 and Windows AMD64). Install from PyPI (or build locally — see from source below).
pip install "vdsim[plot]" # or a local wheel: pip install "./vdsim-*.whl[plot]"
vdsim-quickstart # writes run.csv + run.png in the current directoryThe package ships generic presets only; VDSimPlant() defaults to generic_sedan.
A car whose parameters must stay private is kept outside the package and found first:
export VDSIM_PRIVATE_CONFIGS=/abs/root with /abs/root/vehicles/<name>.yaml (tyre under
/abs/root/parts/tire/), then VDSimPlant(config="<name>.yaml").
Install into a clean environment: another distribution also provides a top-level
vdsim package, and if both are present that one wins the import. VDSim detects
this and raises CoreShadowedError naming the file that was loaded.
Measured on a clean conda env (Python 3.11, Linux x86_64, 2026-06-25): first result in
seconds — pip install [plot] + vdsim-quickstart → run.csv + run.png in ~6 s
wall-clock (cold; lab network, matplotlib wheel included). Repeat quickstart ~1.5 s.
Verify the install:
python -c "import vdsim; from vdsim_lab import Sim; Sim(vehicle='sedan', level='L2')"Source for the script: examples/quickstart.py — uses only
from vdsim_lab import Sim, Road (no repo paths).
Prerequisites: a C++17 compiler, CMake ≥ 3.16, Python ≥ 3.10.
- Linux:
g++ ≥ 9orclang ≥ 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):
pip install ".[plot]"
python -c "import vdsim; print('ok')"Full C++ tree (tests, real-time runtime, FMI, CARLA bridge):
cmake -B build -DVDSIM_BUILD_PYTHON=ON # add -G Ninja on Linux
cmake --build build --config Release # -j on Linux
cd build && ctest -C Release # binaries in build/bin/The canonical suite count lives in one place only — docs/VALIDATION.md. Reproduce it with the pinned preset:
cmake --preset validation && cmake --build --preset validation
ctest --preset validationVDSim gives you the simulation seam; you own the loop and the algorithm. Build
the plant straight from the core (vehicle / tire / level / road) — no scenario file,
no network — then drive it: set_input(action) → run_core_dt(), read
state() / measurements(id).
from vdsim_lab import Sim, Road, Sensors
sim = Sim(vehicle="sedan", level="L2", road=Road.iso8608("C"),
sensors=Sensors().gnss(pos_std=0.3).imu(), v0=12.0,
sensor_mounts={"gnss": {"type": "gnss", "pos": [1.4, 0, 1.0]}})
while not sim.done(12.0):
st = sim.state() # ground truth
gnss = sim.measurements("gnss") # noisy, at the mount
steer, throttle, brake = my_controller(st, gnss) # <-- YOUR algorithm
sim.set_input(steer=steer, throttle=throttle, brake=brake)
sim.run_core_dt() # advance one core step
sim.to_csv("run.csv") # ground-truth + per-wheel log
sim.metrics(["peak_ay", "cte_rms", "lap_time"]) # scalar reductions
sim.plot("run.png", signals=("vx", "ay", "r", "xy")) # optional (needs matplotlib)Copy templates/experiment_template.py, replace controller(...), and run from a
cloned tree:
PYTHONPATH=build/python:python python3 templates/experiment_template.pyRunnable examples (require clone + build): examples/experiment_quickstart.py,
examples/experiment_path_follow.py (pure-pursuit + CTE). Pip users: use
examples/quickstart.py / vdsim-quickstart instead. Full reference (the
seam, Sim(...) options, evidence): docs/EXPERIMENT_API.md.
This is the same set_input → tick seam the real-time server and batch runner use —
in real-time mode the action arrives over UDP, here it comes from your function.
python3 gui/server.py --port 8100 # Windows: python gui\server.py --port 8100Open http://localhost:8100. The GUI auto-starts the real-time runtime and
renders it: 3D view (orbit / chase / cockpit), road & terrain, per-wheel Fz /
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 <host> --udp-port 8101.
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:
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 perstep(), taken before the step is integrated, so the pose is the state attandu_steer/u_fxare the command held over[t, t+control_dt). Returns the resolved decimation.decimation=Nonepicks 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 (Nonewhen 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(the writer emits"0.5"; the fields each version added are listed below) and a requiredrole, either"plant"(the simulator under verification) or"predictor"(the same code driven as an optimiser's internal model).enable_tracedefaults toplantbecauseVDSimPlantis the plant; a producer that builds avdsim_trace.TraceWriterdirectly must passrole=— there is no default, so a predictor run cannot be recorded as plant evidence by omission. Reading a legacy0.1trace still works: a missingroleresolves toplantwith one warning. A0.2or later trace without one is an error. The renderer prints the role in the HUD and never branches on it.
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:
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(defaultoverview). - A preset declares panel channels, labels, y-ranges and BEV layer visibility.
speedis derived fromv_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: trueto 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 inlayout_violations. controlandtire_limitare contracted but not implemented, androad_contactis reserved until the core resolves full contact normals. Those names are refused rather than stubbed.
# 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.0Scenario 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:
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):
vdsim-render run.vdtrace --out run.gif --fps 20 # installed wheel
PYTHONPATH=python python3 -m vdsim_render run.vdtrace --out run.gif # cloned treeOptions: --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:
PYTHONPATH=build/python:python python3 examples/demo_grip_loss.py \
--out demo.gif --keep-tracePass 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:
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.--colorsoverrides the palette,--labelsthe legend (default: manifestrun_id, else the file stem).- Camera:
--follow fit(default) is a static square window holding every route;--follow 1tracks that run with the usual geometry-derived window. --speedsets playback rate relative to wall-clock (--strideis single-run only). Each run's whole route is drawn faintly underneath;--no-ghostremoves 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_fxand 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:
PYTHONPATH=build/python:python python3 examples/demo_compare_runs.py --out compare.gifThree 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
path2doverlay and drawn once as a dashed grey line. The overlaynamemay bewaypoint,reference_pathorref_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 stepk— 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.
vdsim-render run.vdtrace --out run.gif --view-half 100 --preview-frame first-fail--sidecarnames the.npzexplicitly. 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-halfsets 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-failputs 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
<out>_firstfail.pngwritten next to the GIF, whatever--preview-framesays, and the BEV carries aQP 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
eventoverlay namedqp_failwhen the producer attached one, and from the sidecarstatusarray otherwise.
All three flags are single-run only — overlay mode ignores them.
The bird's-eye view above is flat by construction. A run recorded at schema
0.3 also carries attitude, ride height, body accelerations and the road
contacts, and python/vdsim_render3d.py replays that in three dimensions from
the trace file alone — no simulator, no scenario, no re-run:
PYTHONPATH=python python3 -m vdsim_render3d run.vdtrace \
--out results/replay --cameras quarter,side,chase \
--stride 5 --fps 20 --rt1 force,accel,saturation,normalEnd to end, record and replay in one script:
python3 examples/demo_replay3d.py --out results/replay3dThe 2D renderer is untouched: vdsim_render and vdsim_render3d are separate
modules that share only the container reader, so an existing BEV render of the
same trace and preset stays byte-identical.
Render tiers. RT0 is always on — body box, four steered wheels, the ground
grid and the driven trajectory. RT1 is opt-in per layer through --rt1:
| layer | draws | needs |
|---|---|---|
force |
per-wheel contact-force arrows | wheel_F |
accel |
the CG acceleration vector | a_body |
saturation |
wheel colour by friction utilization | wheel_F, wheel_mu |
normal |
per-wheel road normals at the contact points | wheel_road_normal |
refpath |
the path2d overlay, dashed |
a path2d overlay |
--rt1 all turns them all on. Suspension links, tyre deformation and contact
pressure (RT2) are not drawn: the trace has no input for them, so drawing
them would mean inventing one.
Vector lengths are fixed constants — --force-scale (N per drawn metre),
--accel-scale (m/s² per drawn metre) and --normal-scale (drawn length of the
unit normal), all printed on every frame. Nothing autoscales per frame: if the
same arrow length meant different things at different times the video would be
misleading in a way that is very hard to notice.
Cameras are a combination, not a list. A camera is
mount × aim × offset × projection × follow_attitude; the six names are
aliases over that:
| alias | mount | aim | projection | note |
|---|---|---|---|---|
bev3d |
vehicle | vehicle | ortho | top-down, the 3D counterpart of the 2D BEV |
side |
vehicle | vehicle | ortho | for reading roll, pitch and ride height |
quarter |
vehicle | vehicle | persp | three-quarter, the default |
chase |
vehicle | vehicle | persp | behind the car |
map_fixed |
world | fixed_point | persp | whole trajectory in one frame |
map_track |
world | vehicle | persp | fixed viewpoint, follows the car |
follow_attitude defaults to yaw. Roll and pitch are deliberately not
followed: a camera that rolled with the body would keep the horizon level and
make the attitude unreadable, which is the one thing a 3D replay is for.
One file per camera, one pass over the trace. --cameras quarter,side
writes <run_id>__<preset>__<camera>.mp4 plus a preview PNG for each; there is
no grid composite, because splitting the resolution makes the HUD unreadable.
Loading, decimation and the utilization derivation happen once regardless of how
many cameras are drawn, so the total is base + N × draw. Measured on a 30 s L3
run (600 frames, ailab-12): load+prep 0.19 s either way, draw 13.3 s for one
camera and 41.3 s for three.
ffmpeg is used when it is on PATH and is never bundled. Without it the
render still succeeds, writing a PNG sequence and printing the one-line command
that assembles it.
Older traces degrade instead of lying. A 0.2 trace has no pose_zrp, so
the player draws z = 0, roll = pitch = 0, prints the reason once and labels
every frame. And when contact_scope is C0/C1 the core never read the road
normal's x/y, so the normal layer carries a normal: display-only badge — a
tilted road drawn over flat physics is exactly the frame that would otherwise
pass review.
Recording is still opt-in and still costs nothing when off (measured +0.08 %
of step() on the OFF path against the previous revision; the gate is 1 %).
What 0.3 adds:
model_level(L1..L5) andcontact_scope(C0/C1/C2) in the manifest, both required. The level is what lets a reader tell "this model has no roll" from "the roll was never recorded"; the scope declares how far the road normal actually reaches into the physics.geometrygainsmass_kg,cg_height_m,wheel_radius_m,wheel_width_mandbody_lwh_m. A 3D renderer sizes the body box, the wheel cylinders and the CG vector from these — without them it would be guessing, and a guessed body box is a picture that is quietly wrong.- five channels:
pose_zrp(z, roll, pitch),a_body(ax, ay, az),wheel_road_dz,wheel_road_normal,wheel_travel.
a_body comes straight from the dynamics. It is not the derivative of
v_body: at dt = 1 ms that difference is mostly noise, and its magnitude
would change with the decimation setting.
A level that does not have a quantity does not record the channel. An L2
(7-DOF) run has no ride model, so it writes no pose_zrp at all rather than a
column of zeros — a reader must be able to tell absence from zero, and the
writer refuses a channel the declared level cannot produce.
The plant picks its level at construction, and the control contract
u = [delta_rad, Fx_total_N] is the same at every level:
from vdsim_plant import VDSimPlant
plant = VDSimPlant(config="generic_sedan.yaml", level="L3") # 14-DOF ride model
plant.reset([0, 0, 0, 22.0, 0, 0])
plant.enable_trace("run.vdtrace", seed=0, run_id="demo")
...
plant.finalize_trace()level is one of L1..L5; L5 is the free-3D model on its penalty
contact (no hardpoints) and runs with the vdsim_realtime L5 solver settings.
Any other level string is a ValueError here and in every core entry point
(K and L0 are also accepted by the session builders). L5 linearization
and L5 in VecSession are refused explicitly in 0.8.1.
0.1 and 0.2 traces stay readable. A 0.3 file that omits any of the
required fields is an error, not a warning.
0.4 adds one required manifest field, kinematics_attached (bool). It is the
value the suspension-hardpoint attach actually returned for that run, not a
guess from which YAML files exist. The physics of L3 and L4 differ only when
hardpoints are attached, so this field is what tells a reader whether an L3/L4
trace carries suspension kinematics.
- A
0.4trace withoutkinematics_attachedis an error. - A
0.3or older trace reads with the value unknown (None) and one warning. It is never read asfalse. level="L4"with no hardpoints is refused when the session is built (ValueError), so no L4 trace can carry L3 physics.VDSimPlanthas no hardpoint input and refuseslevel="L4"for the same reason. AnExperimentconfig gets hardpoints withkinematics: {front: mp_front_sedan, rear: ta_rear_sedan}.
0.5 records two channels at every level, rp_rate (rad/s, [n,2] = roll
rate, pitch rate) and wheel_travel (m, [n,4]), and adds one required manifest
field, channel_validity, saying per channel whether the model computed it.
- Values are
modeledornot_modeled@<level>; the level must equal the manifestmodel_level. - The source is
IVehicleDynamics::models_channel(), a pure virtual every dynamics model declares in the core. It is not derived from the level number: L5 stroke exists only withl5_spatial_suspension. - L1 and L2 (and K/L0) model neither channel: both are recorded as exactly
0.0and markednot_modeled@L1/not_modeled@L2. A0.0there means "not modeled", not "flat ride"; readers and renderers label it "not modeled". - L3 and above read the rates from the integrated roll and pitch states
(
state.angular_velocity), never by numerically differentiating anything. - A
0.5trace withoutchannel_validityis an error, and the writer refuses non-zero data under anot_modeledchannel. - A
0.4or older trace reads with validityunknownand one warning. It is never read asmodeled. The0.1–0.4fixtures are unchanged;golden_v0_5.vdtraceis the0.5fixture.
A run is one simulation and one .vdtrace. A campaign is a set of runs
declared in a single YAML file. The runner owns only that upper layer: it adds
no field to the trace and calls the render CLI unchanged.
# campaign.yaml
name: mu_sweep
base: step_steer # configs/experiments/<name>.yaml, or an inline scenario
seed: 20260922 # root seed; each run's seed derives from it
duration: 4.0 # optional override [s]
sweep:
grid: # orthogonal product; `list:` names combinations instead
mu: [0.9, 0.7, 0.5]
repeat: 1 # repeat each combination; only the seed movesvdsim-campaign run campaign.yaml --jobs 4 --render overview
vdsim-campaign run campaign.yaml --resume # continue where it stopped
vdsim-campaign run campaign.yaml --dry # print the expansion, run nothing
python3 tools/vdsim_batch.py run campaign.yaml # same runner, historical pathAxis keys are dotted paths into the scenario document (mu, vehicle.*,
tire.* are applied after the preset resolves). Products:
campaigns/mu_sweep/
├── campaign.yaml # the declaration as executed
├── index.jsonl # run_id · axes · seed · status · param_hash · role · trace_path · started_at · wall_s
└── 000/run.vdtrace # one trace per run; run_id is a zero-padded ordinal
import vdsim_campaign as vc
for row in vc.read_index("campaigns/mu_sweep"):
print(row["run_id"], row["status"], row["axes"])Rules worth knowing before you rely on them:
- Deterministic.
seed = derive(root_seed, run_index)— no wall clock, no pid.--jobs 4produces the same channel bytes as--jobs 1. - Failures are isolated, not hidden. One run is one child process; a run
that diverges or crashes costs one index line with
statusdiverged/error/killed. There are no retries unless you ask for--retry N. - Resume verifies.
--resumeskips a run only when its status isok, its trace still exists, and itsparam_hashmatches the declaration. If the declaration changed, the resume is refused. - The index is a lookup table, not a result database. Metrics belong in a consumer script that reads the index.
- A render failure is not a run failure:
statusstaysokandrender_statuscarries the error.
Full schema, index keys and the migration notes for the superseded runners: BATCH_RUNNER.
Vehicles are blueprints over configs/parts/ (chassis, tire, drivetrain, …).
Runs are scenes (configs/scenes/*.yaml) with a fleet[] of blueprint + part
overrides. See CATALOG_AND_PHYSICS for layout,
GUI API, drivetrain inertia, and opt-in LuGre tire.
Aerodynamics (0.8.0). A vehicle file can list named aero elements
(aero.elements: ClA, CdA, x, z, optional y), set air_density, or
keep the four legacy keys with an optional aero_drag_height. What each level
applies, the axle split and the known L5 limit are in
Ch.26 — Aero elements; check aero balance
numbers on L1–L3.
vdsim_realtime is VDSim's real-time application: it runs the same core against
a wall clock and exchanges fixed-format binary UDP packets — CMD
(steer / throttle / brake) in, STATE (pose, velocities, Fz, slip, tire
forces, measured sensors, …) out. This is the SIL / HIL / co-sim boundary; the
GUI and any external controller are just clients of it.
build/bin/vdsim_realtime --scene=configs/scenes/two_vehicle_race.yaml \
--cmd-port=7001 --state-port=7002 --rate=200The wire format is the canonical VDS1 binary protocol (cosim/cosim_protocol.hpp;
contract in docs/vdsim_bridge_interface_requirements.md).
Python clients encode CMD / decode STATE through cosim/protocol.py to stay
byte-compatible.
SI units · body frame ISO 8855 RH (X forward, Y left, Z up) · world frame ENU · wheel index FL = 0, FR = 1, RL = 2, RR = 3.
Apache-2.0 (core / kinematics / tools / validation / FMI export). FMI 2.0 headers are BSD-2-Clause (Modelica Association). Third-party deps (Eigen / yaml-cpp / spdlog / GoogleTest) keep their own licenses; fetched via CMake FetchContent.
