Skip to content

nav: the dynamic bicycle in explicit port-Hamiltonian form, with its energy balance - #748

Merged
transfix merged 4 commits into
masterfrom
feat/dyn-bike-ph
Oct 21, 2026
Merged

transfix merged 4 commits into
masterfrom
feat/dyn-bike-ph

Conversation

@transfix

@transfix transfix commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

P8a's dynamic single-track vehicle (diff::dyn_plant) already has real momentum. This writes it in explicit port-Hamiltonian form (the ladder's rungs 2 and 3, at the plant) and holds dyn_plant to that form. dyn_plant, its adjoint and dyn_bike_golden.json are unchanged: the new functions only evaluate, and DynBike.Golden still passes.

H = 1/2 p^T M^-1 p + V,       p = (m vx, m vy, Iz r)
p' = (J(p) - D(v)) v - dV + G u,   u = (F_x, delta),   y = G^T v
  • J(p): SE(2)'s coadjoint action. It is where dyn_plant's r vy and -r vx come from.
  • D(v): drag and rolling resistance on vx. On (vy, r), the linearized tires: (C_f a a^T + C_r b b^T) / u, with a = (1, lf), b = (1, -lr) and C each tire's cornering stiffness at zero slip given the grip its axle has left. PSD for every u > 0.
  • G: the drive force on p_x; the steering's C_f delta on p_y and lf C_f delta on p_r.
  • The functions: dyn_ph_hamiltonian, _momentum, _J, _D, _G, _input, _rhs and _power. dyn_ph_tangent_D gives the Pacejka tires' tangent dissipation, which is PSD exactly while both tires are short of their peak.

Measured (nav_dyn_bike_test DynBikePH, a Humvee from its vehicle_desc in SI)

  • Structure: J is skew, D is symmetric PSD, and dH/dp = v.
  • Vector field: over a 0.1 ms step, dyn_plant's change in momentum is the pH vector field to 0.22% of m g in the linear regime.
  • Energy balance along dyn_plant's own trajectories (drive, brake and a weaving steer, flat and on a 3% grade). Delta H = -int v^T D v + int y^T u - int m g grade vx holds to 1.4% of the energy flow at every step from 0.005 s to 0.06 s.
    • The residual comes from the linearization. As the steering amplitude halves from 0.04 to 0.005 rad it falls 5.0e-2, 1.4e-2, 3.5e-3, 9.0e-4, so the integrator's step adds nothing measurable.
  • Where D stops being PSD. The tangent D, binned by lateral g along steer ramps to the 0.6 rad lock and swerves at 8-20 m/s. The tires are physics: fit dyn_bike_si_of to Jolt (tires, direct drive); P8b training now helps on Jolt #753's, fitted to Jolt (B C = 1.12 / tire_peak_slip_angle):
lateral g mu 0.9: indefinite mu 0.6: indefinite
0.0-0.3 0% 0%
0.3-0.5 0% 50% (front tire past its peak)
0.5-0.7 25% (front tire past its peak) 35% (front tire past its peak)
0.7-0.8 43% (front tire past its peak) n/a
0.8-0.9 0% (swerve transients only) n/a
  • The Humvee understeers. Its front tire passes its peak from about half the grip, the rear never does, and the steady ramps plateau short of mu g.
  • Past that point a positive-definite learned D cannot represent the vehicle: it slides.
  • On the spec-sheet tires before physics: fit dyn_bike_si_of to Jolt (tires, direct drive); P8b training now helps on Jolt #753, the peak came later: indefinite only from 0.8 g at mu 0.9 (48% of samples there), and from 0.5 g at mu 0.6 (55%).
  • The gate asserts PSD below half the grip, indefiniteness somewhere above it, and the rear tire never past its peak.

Locally (Linux, master merged in, with #753): nav_dyn_bike_test 13/13 and nav_coef_train_test 14/14.

…energy balance

P8a's dynamic single-track vehicle (diff::dyn_plant) already has real momentum. This writes
it as a port-Hamiltonian system (doc 20's rungs 2 and 3, at the plant) and holds dyn_plant to
that form. dyn_plant, its adjoint and its goldens are unchanged; the new functions only
evaluate.

  H = 1/2 p^T M^-1 p + V,  p = (m vx, m vy, Iz r)
  p' = (J(p) - D(v)) v - dV + G u,  u = (F_x, delta),  y = G^T v

- J(p): SE(2)'s coadjoint action, which is where dyn_plant's r vy and -r vx come from.
- D(v): drag and rolling resistance on vx, and on (vy, r) the linearized tires,
  (C_f a a^T + C_r b b^T) / u with a = (1, lf), b = (1, -lr), C the cornering stiffness at
  zero slip with the axle's remaining grip. PSD for every u > 0.
- G: the drive force on p_x and the steering's C_f delta on p_y, lf C_f delta on p_r.
- dyn_ph_hamiltonian, _momentum, _J, _D, _G, _input, _rhs, _power, and dyn_ph_tangent_D, the
  Pacejka tires' tangent dissipation, PSD exactly while both tires are short of their peak.

DynBikePH gates (Humvee from its vehicle_desc, SI):
- the structure: J skew, D symmetric PSD, dH/dp = v;
- dyn_plant's momentum change over a 0.1 ms step is the pH vector field to 0.9% of m g in the
  linear regime;
- along dyn_plant's own trajectories (drive, brake, a weaving steer, flat and a 3% grade),
  Delta H = -int v^T D v + int y^T u - int m g grade vx to 1.3% of the energy flow at every
  step from 0.005 s to 0.06 s. The residual is the linearization's: 4.3e-2, 1.3e-2, 3.4e-3,
  9.0e-4 as the steering amplitude halves from 0.04 to 0.005 rad;
- the tangent D by lateral g along steer ramps and swerves: PSD in every sample below 0.8 g
  at mu 0.9 (below 0.5 g at mu 0.6); indefinite in 48% of the 0.8-0.9 g samples (55% of the
  0.5-0.7 g ones at mu 0.6), always the front tire past its peak first (understeer).
…rom half the grip)

With #753's tires (B C = 1.12 / tire_peak_slip_angle) the Pacejka peak sits
at a larger slip, so the 0.25 rad steer ramps no longer reached it. The ramps
now go to the 0.6 rad lock. The Humvee understeers: its front tire passes the
peak at 0.5-0.8 g on mu 0.9 (0.3-0.7 g on mu 0.6), and the rear never does.
The gate is PSD below half the grip, indefinite somewhere above it, and the
rear tire never past its peak.
@transfix
transfix merged commit ce28bc8 into master Oct 21, 2026
25 of 26 checks passed
transfix added a commit that referenced this pull request Oct 27, 2026
… books (#831)

The explicit pH form of the dynamic single-track plant (#748) booked the
steering angle as a power port, G = [[1,0],[0,C_f],[0,lf C_f]] with
u = (F_x, delta), so with no drive force the model could gain kinetic energy
from steering alone. It held only in the linear-tire regime, and its comment
said a positive-definite dissipation cannot represent sliding, which is true
of the tangent only.

Rewritten so that the form is dyn_plant's vector field exactly, at every slip:

  p' = (J(p) - R(v, delta)) v + G(delta) u + f_eps - m g grade e_x
  R  = D_x e_x e_x^T + (S_f / cos delta) n_f n_f^T + S_r n_r n_r^T
  G  = [t_f, t_r],  u = (F_xf, F_xr),  y = G^T v

- n_f = (-sin d, cos d, lf cos d) and n_r = (0, 1, -lr) carry each tire's
  lateral force onto (vx, vy, r). S_f and S_r are the tires' secants through
  their roots (dyn_secant_w), >= 0 at any slip, so R is PSD sliding or not.
- The inputs are the axles' applied forces along their wheels. Steering only
  turns n_f and t_f, so it does no work.
- f_eps is what the secants leave of the tire forces: the slips' speed floor.
  The front force's root sits at u tan(sg delta), not at vx tan(delta). It is
  booked on its own line and vanishes as eps / |vx| -> 0.
- dH/dt = -v^T R v + y^T u + v^T f_eps - m g grade vx.

dyn_plant takes an optional dyn_step_free*, which records the twist before the
speed clamp and the low-speed blend. Its outputs are unchanged (DynBike.Golden
holds). dyn_plant_books returns one step's books: dH, the trapezoid of each
power, the limiters' kinetic energy, and the residual.

Gates (nav_dyn_bike_test, DynBikePH):
- the structure at 500 states per mu, half of them past a tire's peak:
  J skew, R symmetric with nonnegative coefficients, no supply without axle
  force;
- the vector field against a 1e-5 s dyn_plant step, forward and reversing,
  flat and on 3%: worst 1.6e-4 m g (was a 1% linear-regime check);
- the step books on a swerve past the peak: the per-step residual falls
  3.8-3.95x per halving of hdt (second order), and is 1.2e-3 of the flow over
  6 s at 0.01 s. Braking to a stop charges the clamp and the blend;
- the floor term scales as eps^2 and is 2e-16 of the flow at eps = 1e-9;
- the tangent goes indefinite above half the grip while the secant never does.

cvc::ph::se2_vehicle gains:
- output(), the conjugate output y = G(v)^T v;
- mass();
- noise_power(), 1/2 tr(Sigma^T M Sigma): the twist noise adds this to H on
  average, which the drift's balance does not book.

Tested: y.u is the supplied power, and an antithetic Monte Carlo of one step
measures 3.585e-6 J against 3.564e-6 J predicted. The clang-format-garbled
equation comments in se2_vehicle.h, se2_train.h and se2_train.cpp are restored
and fenced.
transfix added a commit that referenced this pull request Oct 27, 2026
…lant (#833)

* nav: dyn_bike is port-Hamiltonian at every slip, with per-step energy books

The explicit pH form of the dynamic single-track plant (#748) booked the
steering angle as a power port, G = [[1,0],[0,C_f],[0,lf C_f]] with
u = (F_x, delta), so with no drive force the model could gain kinetic energy
from steering alone. It held only in the linear-tire regime, and its comment
said a positive-definite dissipation cannot represent sliding, which is true
of the tangent only.

Rewritten so that the form is dyn_plant's vector field exactly, at every slip:

  p' = (J(p) - R(v, delta)) v + G(delta) u + f_eps - m g grade e_x
  R  = D_x e_x e_x^T + (S_f / cos delta) n_f n_f^T + S_r n_r n_r^T
  G  = [t_f, t_r],  u = (F_xf, F_xr),  y = G^T v

- n_f = (-sin d, cos d, lf cos d) and n_r = (0, 1, -lr) carry each tire's
  lateral force onto (vx, vy, r). S_f and S_r are the tires' secants through
  their roots (dyn_secant_w), >= 0 at any slip, so R is PSD sliding or not.
- The inputs are the axles' applied forces along their wheels. Steering only
  turns n_f and t_f, so it does no work.
- f_eps is what the secants leave of the tire forces: the slips' speed floor.
  The front force's root sits at u tan(sg delta), not at vx tan(delta). It is
  booked on its own line and vanishes as eps / |vx| -> 0.
- dH/dt = -v^T R v + y^T u + v^T f_eps - m g grade vx.

dyn_plant takes an optional dyn_step_free*, which records the twist before the
speed clamp and the low-speed blend. Its outputs are unchanged (DynBike.Golden
holds). dyn_plant_books returns one step's books: dH, the trapezoid of each
power, the limiters' kinetic energy, and the residual.

Gates (nav_dyn_bike_test, DynBikePH):
- the structure at 500 states per mu, half of them past a tire's peak:
  J skew, R symmetric with nonnegative coefficients, no supply without axle
  force;
- the vector field against a 1e-5 s dyn_plant step, forward and reversing,
  flat and on 3%: worst 1.6e-4 m g (was a 1% linear-regime check);
- the step books on a swerve past the peak: the per-step residual falls
  3.8-3.95x per halving of hdt (second order), and is 1.2e-3 of the flow over
  6 s at 0.01 s. Braking to a stop charges the clamp and the blend;
- the floor term scales as eps^2 and is 2e-16 of the flow at eps = 1e-9;
- the tangent goes indefinite above half the grip while the secant never does.

cvc::ph::se2_vehicle gains:
- output(), the conjugate output y = G(v)^T v;
- mass();
- noise_power(), 1/2 tr(Sigma^T M Sigma): the twist noise adds this to H on
  average, which the drift's balance does not book.

Tested: y.u is the supplied power, and an antithetic Monte Carlo of one step
measures 3.585e-6 J against 3.564e-6 J predicted. The clang-format-garbled
equation comments in se2_vehicle.h, se2_train.h and se2_train.cpp are restored
and fenced.

* physics: dyn_bike_plant, the dynamic single-track vehicle as a live plant

nav's agents can now drive the dynamic single-track model (diff::dyn_plant:
Pacejka tires, load transfer, actuator lag) as their plant, beside nav's
kinematic bicycle and the Jolt vehicles. It needs no physics world and no Jolt.
This is the plant rollout_kind::dynamic trains through, so a policy trained
there runs on its own model.

cvc::physics::dyn_bike_plant (a cvc::nav::vehicle_plant):
- Agent i drives descs[i % n], with parameters from nav_plant::dyn_bike_si_of,
  so both plants read one vehicle_desc. Its rear axle starts on the agent's
  start, at rest.
- Each tick runs ceil(dt / substep) dyn_plant steps (0.01 s by default) under
  the held command, in SI.
- Every step is booked through dyn_plant_books. books(i) and last_tick(i) give
  kinetic, drive, brake, dissipated, floor, grade, limiter and residual in J,
  and H - H0 = drive - brake - dissipated + floor + grade + limiter + residual
  holds to rounding.
- energy_step() is the tick's drive energy; sample() gives vx, vy, r, the
  steer, the commands and a_lat / a_lon as nav_plant measures them.
- state() reads back the rear axle, heading, speed and steer, with tire_use
  the most used tire's slip over its Pacejka peak.
- limits() follows nav_plant's plan: steering, braking, latency, and with
  plan_with_vehicle the vehicle's acceleration, top speed, cornering and size.

plant_host takes "dynamic" (the Humvee) or "dynamic:<presets>" (cycled, as the
Jolt presets are):
- check() and use() accept them with or without Jolt in the build;
- dynamic() and active() return the plant;
- plant() and physics() stay null on it;
- it drives on the host's ground_mu.

plant_kind::dynamic is reported by sim_world's tree (nav.plant) and by pycvc's
nav_sim_world_plant_kind.

physics_dyn_bike_plant_test:
- the plant's ticks are dyn_plant's substeps bit for bit;
- the books close (identity to 1e-9 of the flow) on the Humvee and the
  RoboRacer, with the integrator's residual 1.1e-4 and 8.9e-4 of the flow over
  30 s of weaving and braking;
- plant_host puts a 3-agent sim_world on "dynamic" with no physics world, and
  every agent reaches its goal 120 m on in 11.5 s;
- "dynamic:humvee,roboracer" cycles presets, an unknown preset throws and
  changes nothing, and "kinematic" takes the agents off again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant