v0.2 — pluggable vehicle subsystems (design, for review)¶
Status: design draft (2026-06-05), pending review before implementation.
Goal¶
Make each vehicle subsystem a pluggable module with a clear input -> output contract, a simple default implementation, and a path for a future team (brake, steering, …) to drop in their own algorithm or physics without touching the dynamics core. "Pave the way" — the v0.1 core hard-codes these inside the dynamics; v0.2 extracts them behind interfaces.
Command layering¶
Three layers, top to bottom:
Driver / algorithm command (what a human or a controller sends)
handwheel_angle, throttle, brake_pedal, gear, handbrake
| steering / brake / drivetrain modules convert
v
Actuator-level inputs to the dynamics core
per-wheel steer (road-wheel angle or rack travel),
per-wheel brake torque[4], per-wheel drive torque[4]
| dynamics core integrates; calls suspension inside the step
v
State (pose, velocities, wheel spin, Fz, forces, …)
The wire/DriverCmd carries the top layer; the modules produce the middle
layer the core consumes. Today the core computes the middle layer internally — the
refactor moves that into the default modules (see Integration).
Module interfaces (C++)¶
All modules are C++. Every call receives a shared context with the full vehicle state, so a module can reference (almost) any state it needs, not just its narrow inputs (decision 1):
struct SubsystemContext {
const VehicleState& state; // full state: pose, vel, wheel_spin, Fz, slip, ...
const DriverCmd& cmd; // handwheel_angle, throttle, brake, gear, handbrake
double dt;
};
Each module also owns its own config (params + deadtime_s) and any internal
state. The per-module structs below name the primary signals; the full context is
always available.
Brake — pedal -> per-wheel brake torque¶
struct IBrakeSystem {
// returns per-wheel brake torque [N.m, magnitude]; reads cmd.brake + state.Fz etc.
virtual std::array<double,4> wheel_torque(const SubsystemContext&) = 0;
virtual void reset() {}
virtual ~IBrakeSystem() = default;
};
ProportionalBrake: T_i = pedal * max_brake_torque * split_i
(front/rear bias, EBD by Fz) — reproduces today's behaviour exactly.
- Deadtime: the module delays pedal by deadtime_s (transport lag) before the map.
- Future (brake team): booster -> line pressure -> pad mu -> caliper torque, thermal
fade, ABS — same interface, richer internals.
Steering — handwheel -> road-wheel angle / rack travel¶
struct SteeringOutput {
double roadwheel_angle; // rad -> used by Lk / Ld1 (axle / bicycle)
double rack_travel; // m -> used by Ld2/Ld3/Ld4 (-> Ackermann/Ld4 -> wheel)
};
struct ISteeringSystem { virtual SteeringOutput apply(const SubsystemContext&) = 0; /* reset/dtor */ };
cmd.steer is the handwheel angle (decision 2).
- Default RatioSteering: roadwheel = handwheel / steering_ratio,
rack = handwheel * rack_gain (both consistent; lower models read the angle,
higher models read the rack and run the existing Ackermann/Ld4 map).
- Convenience UnitySteering (ratio = 1): handwheel == road-wheel angle, for
callers that want to command the road wheel directly.
- Deadtime on the handwheel input (decision 5). Future: MDPS assist, speed-variable
ratio, column compliance.
Drivetrain — throttle -> per-wheel drive torque¶
struct DrivetrainOutput { std::array<double,4> wheel_torque; /* +engine_omega later */ };
struct IDrivetrain { virtual DrivetrainOutput apply(const SubsystemContext&) = 0; /* reset/dtor */ };
BasicDrivetrain (skeleton, decision 7): engine torque =
throttle * max_motor_torque * final_drive, split by the diff (open/locked/LSD,
existing logic) to the driven axle — reproduces today's behaviour. Reads
state.wheel_spin for the diff split.
- Deadtime on throttle.
- v0.3: the engine torque-RPM curve, plus engine inertia + coupled diff from
V0.2_DRIVETRAIN.md (fixes the Issue-2 inner-wheel spin), fill this module then.
Suspension — (deflection, rate, params) -> force¶
struct CornerInput { // per corner, supplied by the core each substep
int corner; // 0=FL 1=FR 2=RL 3=RR
double defl; // m, compression +
double defl_rate; // m/s
double damping_scale; // live-adjustable dial, default 1.0
};
struct ISuspension {
virtual double force(const SubsystemContext&, const CornerInput&) = 0; // N, per corner
/* reset/dtor */
};
LinearSuspension: F = k*defl + c*damping_scale*defl_rate (+ bump stop
later) — matches today's L3 spring/damper. Per corner (decision 6).
- Called inside the dynamics step, per corner, where the vertical/roll states
live (Ld3 uses it; Ld2 quasi-static; Ld1/Lk n/a).
Anti-roll bar — separate module (decision 6)¶
struct AxleDefl { double defl_left, defl_right, rate_left, rate_right; };
struct IAntiRollBar {
// returns the per-wheel vertical force pair the bar adds on this axle (+/-),
// from the L/R deflection difference. front and rear bars are separate instances.
virtual std::pair<double,double> force(const SubsystemContext&, const AxleDefl&) = 0;
};
LinearARB: F = k_arb * (defl_left - defl_right), applied +on one side
/ -on the other. One instance per axle. Decoupled from the per-corner suspension
so either can be swapped independently.
Deadtime¶
A transport delay = ring buffer of past inputs, depth round(deadtime_s / dt).
Embedded in the drivetrain (throttle) and brake (pedal) modules per the
request; a reusable DelayLine helper. Steering deadtime optional (open question 4).
Integration — the Vehicle assembly¶
A Vehicle owns the dynamics core + the four modules:
Vehicle::step(DriverCmd cmd, env, dt):
ctx = { state, cmd, dt } // full state visible to every module
steer = steering.apply(ctx) // -> roadwheel angle or rack travel
Tb[4] = brake.wheel_torque(ctx)
Td[4] = drivetrain.apply(ctx)
core.set_actuators(steer, Tb, Td)
core.step(dt) // inside the substep the core calls suspension.force(ctx, corner)
// per corner and the ARB module per axle for the vertical states
This needs the dynamics core to accept actuator-level inputs (per-wheel
Td/Tb, steer/rack) instead of computing them from CmdL4 internally.
Migration that keeps the 190 ctest green: make the default modules numerically
identical to the current internal computation, and route the core through them. A
CmdL4 call then == "core + default modules", so existing results don't move; the
Vehicle assembly swaps in custom modules when configured.
Configuration & pluggability¶
- Each subsystem selected in the vehicle YAML, like the tire model today:
brake: {type: proportional, max_torque: 2000, deadtime: 0.02, bias_front: 0.6}. - A type registry maps
type-> factory; a new team registers a new type implementing the interface. (Open question 3: also allow Python-implemented subsystems via a pybind trampoline, so algorithm teams stay in Python.)
Per-level applicability¶
| Module | Lk | Ld1 | Ld2 | Ld3 | Ld4 |
|---|---|---|---|---|---|
| Steering out | road-wheel angle | road-wheel angle | rack | rack | rack |
| Brake | (kinematic) | yes | yes | yes | yes |
| Drivetrain | (kinematic) | yes | yes | yes | yes |
| Suspension (per corner) | n/a | n/a | quasi-static | yes (dynamic) | yes |
| Anti-roll bar | n/a | n/a | quasi-static | yes | yes |
Note (decided 2026-06-05): Ld1 (bicycle) is exempt from per-wheel module routing. It is a single-track model (one omega per axle, axle-level drive/brake), so the 4-wheel modules do not map cleanly (0.5/0.5 split + zero FR/RR omega → factor-2 / diff-bias mismatch). Ld1 keeps its inline axle-level computation; the modules target Ld2+ (Ld3 gets drive/brake/steer for free since it delegates the planar solve to an inner Ld2). Ld3-only suspension/ARB are wired separately (the ARB enters as a roll moment in Ld3, so the per-wheel-force module needs an equivalent mapping).
Decisions (reviewed 2026-06-05)¶
- Modules see the full vehicle state. Every module call receives a
SubsystemContextwith a const reference to the wholeVehicleState(pose, velocities, wheel spin, Fz, slip, …) plus theDriverCmdanddt— not just the narrow inputs. A module may use as much of the state as it needs. - CMD
steeris the handwheel angle (steering module runs server-side). For convenience also provide a unity steering module (ratio = 1) so handwheel == road-wheel angle for callers that want to command the road wheel directly. - Core routes through default modules (defaults == current behaviour, 190 stays green). Confirmed.
- All core + dynamics + subsystems are C++. No Python-implemented subsystems (the pybind trampoline idea is dropped); Python stays a client/config layer.
- Deadtime on steering too, in addition to drivetrain and brake (each module
owns a
deadtime_s, default 0). - Anti-roll bar is its own module, separate from suspension; suspension is per-corner (scalar force), the ARB module adds the cross-corner L/R coupling.
- Module skeletons first (interfaces + default flat behaviour). The engine torque-RPM curve moves to v0.3; engine inertia + coupled diff land with the engine model then (Issue 2 stays documented until then).
Relationship to other v0.2 docs¶
V0.2_DRIVETRAIN.md— the engine-inertia + coupled-diff physics that fills in the defaultIDrivetrain.V0.2_TIRE_LUGRE.md— the tire is already an interface (ITireModel); LuGre is a new impl, same pattern as these modules.V0.2_MULTIVEHICLE.md— orthogonal; each vehicle in the world owns its own module set.V0.2_PLAN.md— these are WS1 (subsystem models) + the pluggability for WS2 (workshops edit each module's params).