Skip to content

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;
};
- 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 */ };
- Default 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 */
};
- Default 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;
};
- Default 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)

  1. Modules see the full vehicle state. Every module call receives a SubsystemContext with a const reference to the whole VehicleState (pose, velocities, wheel spin, Fz, slip, …) plus the DriverCmd and dt — not just the narrow inputs. A module may use as much of the state as it needs.
  2. CMD steer is 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.
  3. Core routes through default modules (defaults == current behaviour, 190 stays green). Confirmed.
  4. All core + dynamics + subsystems are C++. No Python-implemented subsystems (the pybind trampoline idea is dropped); Python stays a client/config layer.
  5. Deadtime on steering too, in addition to drivetrain and brake (each module owns a deadtime_s, default 0).
  6. 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.
  7. 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 default IDrivetrain.
  • 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).