Skip to content

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

Merged
transfix merged 2 commits into
masterfrom
feat/dyn-bike-passive-ph
Oct 27, 2026
Merged

transfix merged 2 commits into
masterfrom
feat/dyn-bike-passive-ph

Conversation

@transfix

Copy link
Copy Markdown
Owner

Summary

#748's explicit port-Hamiltonian form of the dynamic single-track plant had three problems:

  • It booked steering as a power port (G = [[1,0],[0,C_f],[0,lf C_f]], u = (F_x, delta)), so with no drive force the model could gain energy from steering alone.
  • It held only in the linear-tire regime.
  • Its comment said a PSD dissipation cannot represent sliding. That is true only of the tangent.

This PR rewrites the form so that it 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   (PSD: S_f, S_r are the tires' secants, >= 0 at any slip)
G  = [t_f, t_r],  u = (F_xf, F_xr) the axles' applied forces,  y = G^T v
dH/dt = -v^T R v + y^T u + v^T f_eps - m g grade vx
  • Steering only turns n_f and t_f, so it does no work.
  • f_eps is 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 scales as eps^2.
  • J is SE(2)'s coadjoint (Lie-Poisson) structure.

dyn_plant gains an optional dyn_step_free* that records the twist before its speed clamp and low-speed blend. The step's outputs are unchanged, and DynBike.Golden passes. dyn_plant_books returns one step's energy books:

  • dH;
  • the trapezoid integral of each power;
  • the limiters' kinetic energy;
  • the residual.

cvc::ph::se2_vehicle gains:

  • output(), the conjugate output y = G(v)^T v;
  • mass();
  • noise_power(), ½ tr(Σᵀ M Σ): the twist noise adds this energy on average, outside the drift's balance.

The clang-format-garbled equation comments in se2_vehicle.h, se2_train.h and se2_train.cpp are restored and fenced.

Gates (Windows, MSVC, Release)

Test Result
DynBikePH.TheStructureIsPortHamiltonianAtEverySlip 500 states per mu, 244 past a tire's peak: J skew, R symmetric with nonnegative coefficients, no supply without axle force
DynBikePH.TheVectorFieldIsThePlantsAtEverySlip against a 1e-5 s dyn_plant step, forward/reverse, 0%/3% grade: worst 1.6e-4 m g
DynBikePH.StepBooksCloseToSecondOrder swerve past the peak: per-step residual x3.83, x3.92, x3.95 per halving of hdt; 1.2e-3 of the flow over 6 s at hdt 0.01; braking to a stop charges the clamp and blend
DynBikePH.TheFloorTermVanishesWithTheFloor 5.4e-4 → 5.4e-6 → 5.4e-8 → 2e-16 of the flow as eps falls 0.5 → 1e-9
DynBikePH.TheSecantStaysPSDWhereTheTangentDoesNot tangent indefinite above ~0.5 mu g; secant never
PhSe2.EnergyBalanceIsExact y·u equals the supplied power
PhSe2.MassInvertsTheInverseMass 1e-12
PhSe2.NoisePowerIsTheEnergyTheDiffusionAdds 3.585e-6 J measured (se 2.6e-8) vs 3.564e-6 J predicted

Regression: nav_dyn_bike_test (21), ph_se2_test (14), ph_se2_train_test (8), nav_dyn_4w_test (16), physics_plant_log_test (13), nav_material_bike_rollout_test (12): all pass.

dyn_ph_* has no callers outside these tests. GRL-SNAM's dyn_bike twin reads only dyn_plant's values, which are unchanged.

… 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.
@transfix
transfix merged commit 950154b into master Oct 27, 2026
24 checks passed
transfix added a commit that referenced this pull request Oct 27, 2026
The project version and the cvcpkg recipe family move to 3.7.1. The recipes that carried
3.7.0-lineage revisions (libcvc, cvcgl, libcvc-examples-web, pycvc-gl-cp312) restart at
cvc_revision 1, since a version bump restarts it.

3.7.1 carries dyn_bike's port-Hamiltonian form and per-step energy books (#831), the
navigator's per-step energy identity and context work (#832), and the dynamic single-track
plant for plant_host (#833).
transfix added a commit that referenced this pull request Oct 28, 2026
…ooks (#850)

The four-wheel plant (`inc/cvc/nav/detail/dyn_4w.h`, P8d) now has an explicit port-Hamiltonian form and per-step energy books. It follows the single track's form from #831 (`dyn_ph_*`, `dyn_plant_books`).

## The form

In x = (q, p), with q = (z, φ, θ) and p = M ξ for the twist ξ = (vx, vy, r, ż, φ̇, θ̇), `dyn4_plant`'s vector field is exactly:

```
H  = ½ ξᵀ M ξ + Σᵢ ½ kᵢ lᵢ² + m g z          lᵢ = Fz0ᵢ/kᵢ + hgᵢ − cᵢ·q
x' = (J(p) − R) ∂H/∂x + G u + f_lever         u = (F_x1 … F_x4),  y = Gᵀ ∂H/∂x
J  = [[0, 0, I], [0, J_h(p_h), 0], [−I, 0, 0]]
R  = blockdiag(0, c_x e_x e_xᵀ + Σᵢ Sᵢ nᵢ nᵢᵀ, Σᵢ c'ᵢ cᵢ cᵢᵀ)
```

- **No speed-floor term (f_ε = 0).** Each slip is measured in its wheel's frame and rooted at wᵢ = nᵢ·v = 0. The exact secant Sᵢ = −F_yᵢ / wᵢ (`dyn4_ph_secant`) therefore carries the whole lateral force. Sᵢ ≥ 0 at any slip and any steering angle, so R is positive semi-definite whether the tires slide or not.
- **H** holds the corners' springs and gravity on heave. **R**'s vertical block holds the dampers.
- **G**'s columns are the wheels' drive and brake forces at their contact patches, h below the centre of gravity. So yᵢ includes the roll and pitch rates, and yᵀu is all the power those forces deliver. Steering only turns nᵢ and G's columns, so it does no work.
- **f_lever is the one term that differs from the single track.** It is the moment the tires' lateral forces exert on roll and pitch (h·FY). The plant's slips never see the body's roll and pitch rates, so this coupling runs one way only: it can't go in J or in R. It gets its own line in the books, and it is zero when h = 0.
- **Energy rate:** dH/dt = −∂Hᵀ R ∂H + yᵀu + ξᵀ f_lever, plus the ground lifting the springs as the wheels roll over it (zero on flat ground).

## The grade force (opt-in)

`dyn_4w` has no gravity along the ground. The terrain only lifts the springs, so a vehicle climbs a hill for free, and the books show that energy as the **ground line**, coming from outside.

`dyn4_params::grade_force` (and `dyn4_si::grade_force`) adds it. It is **off by default**, so the plant, its goldens and GRL-SNAM's twin are unchanged.
- `dyn4_grade` applies each spring's horizontal reaction on the slope under its wheel, −Fᵢ ∇h(Pᵢ), as a force and yaw moment on (vx, vy, r).
- That is minus the gradient of H's springs in the pose, so it's conservative. With the pose in the state it belongs to J, and the terrain's energy is in H, as the paper describes.
- The slope comes from central differences of the ground sampler over 1e-3 L, so any sampler works.
- The pH form gains `f_grade` (`dyn4_ph_grade`), and the rates and books gain a **grade** line. Its power cancels the ground line's.

## The books

`dyn4_plant` takes an optional `dyn_step_free*`, as `dyn_plant` does. `dyn4_plant_books` returns each step's books: dH = supplied − dissipated + lever + grade + ground + limiter + residual.
- The ground line is exact: the springs' energy at the step's end, on the new ground minus on the ground the step stood on.

**The plant's outputs and VJP are unchanged.** On the Windows build, `dyn4_golden.json` and `dyn4_ext_rollout_golden.json` regenerate byte-for-byte the same from this branch as from master. GRL-SNAM's `test_dyn4_parity.py` fixtures stay valid.

## Gates (`nav_dyn_4w_test`, `Dyn4PH.*`)

- **Structure.** 500 states on each of: flat ground (μ 0.9 and 0.6) and the golden's split-and-bump ground, sprung and rigid. 267 of the 500 have a tire past its peak.
  - J is skew.
  - R is symmetric, with λ_min ≥ −1e-9‖R‖.
  - ∂H/∂x matches H's central differences to 6e-8.
  - Nothing is supplied when the wheels' forces are zero.
- **Vector field.** A 1e-5 s `dyn4_plant` step against `dyn4_ph_rhs`, forward and reversing, on flat and split-and-bump ground. Worst error: 2.0e-4 of each row's scale.
- **Step books.** 8 s of a 0.2 Hz weave to near full lock (the steering slews at 0.35 rad/s), with 27% of steps having a tire past its peak (slips reach 0.68 rad; the peak is at 0.345).
  - The per-step residual falls 3.85–3.98× per halving of hdt, on flat ground and over hills.
  - At hdt 0.01, |Σ residual| / flow is 5.0e-4 on flat ground and 1.0e-3 over hills.
  - Braking to a stop charges the speed clamp and the low-speed blend.
  - The ground line is zero on flat ground; the lever line is zero with h = 0.
- **The grade force is −∂H/∂pose.** Checked against H's central differences in (x, y, ψ): within 3.6e-7 m·g on the golden's bump, and 2.1e-8 m·g over hills. The vector-field gate also passes with it on.
- **Grade force on versus off, over hills, swerving past the peak.**
  - Off: the ground line is +2868 J at hdt 0.01.
  - On: ground is +3373 J and grade −3372 J.
  - |ground + grade| per step falls 4.0× per halving of hdt (second order).
- **Coasting up a 5% ramp** for 2 s from 10 m/s, with no drag and the dampers nearly off:
  - Off: the speed holds, and H gains m·g·climb, all of it from the ground line.
  - On: the coast loses 0.986 m/s against g·s·t = 0.981, and H holds to within 0.8% of the climb at hdt 0.0025.
  - The remaining error is the step holding the ground at its start. It is first order: 4× smaller than at hdt 0.01.

Two findings about the plant, left for a separate decision (not changed here):
- **The lever term is one-way** (above). Making it reciprocal would change the plant and its goldens.
- **The dampers are skyhook dampers.** They act on the body's own vertical rate, not on its rate relative to the ground. With them, a steady climb also pays c·ż² per corner (about 11 kJ on that ramp); the books show it as dissipation.

Run locally on Windows (MSVC, Release): `nav_dyn_4w_test` 21/21 (including `Dyn4.Golden` and `Dyn4ExtRollout.Golden`) and `nav_dyn_bike_test` 21/21.
transfix added a commit that referenced this pull request Oct 28, 2026
…s the port (#851)

In LieSPHGP's form every logged input is a port, steering included, so y^T u
books power through the steering: on the golden fit the steering is 13.4% of
|y.u| at its 16 states and supplies energy at 10 of them, with no drive at all.
A vehicle's steering does no work on its body (dyn_bike's pH form, #831): it
only turns the directions its tires push along.

se2_setup::modulation_dim (0 by default) makes the last inputs modulations:
D(v, delta) and G(v, delta) read them beside the twist, u is the remaining
ports, and y = G(v, delta)^T v. With the drive at zero nothing is supplied
whatever delta is, and the steering still steers through D and G.

- The modulations are scaled like the ports (control_scale has one entry per
  input, ports first). Plant logs (drive, steering) fit with control_dim 1 and
  modulation_dim 1.
- A modulated model is written as cvc-ph-se2/2 (modulation_dim, D_input and
  G_input "twist+modulation"); models without modulations are still /1, byte
  for byte, and a /1 reader refuses /2.
- The reverse pass carries the GP input's modulation entries and drops their
  gradient (they are data).
- cvc ph-train --steer-modulates fits it; --init refuses a model that
  disagrees with the flag; provenance records "steering".
- modulation_dim 0 is unchanged: the JAX parity tests hold as before.

Tests:
- ph_se2_test, 14 -> 18:
  - TheReferenceFormBooksSteeringAsPower: the golden fit's steering share of
    |y.u| is 0.134; it supplies energy at 10 of 16 states;
  - ModulatedSteeringSuppliesNoPower: the balance to 1e-11 at 200 states,
    zero supply at zero drive, and a 0.5 steer step changes the twist's rate
    by at least 0.18;
  - ModulatedModelRoundTripsExactly;
  - ModulatedStepIsSecondOrder: x4.29, 4.08, 4.02.
- ph_se2_train_test, 8 -> 11:
  - ModulatedGradientMatchesFiniteDifferences: worst 3.9e-7 over 68 entries;
  - ModulatedShortFitLowersTheLoss: 1.417 -> 0.070;
  - BothFormsFitTheDynamicBicycle: both forms fit diff::dyn_plant's flights
    (Adam 1e-3: reference 8.52 -> 0.0006, modulated 7.63 -> 0.0092). The
    reference fit still books 5.6% of |y.u| through steering and supplies
    energy by steering alone at 74 of 250 held-out steps; the plant books
    none. At Adam's default 3e-3 the reference fit's gradient norm blew up
    (1e6-1e9) in three runs; the modulated fit converged in all four tried.
- cvc_cli_test: PhTrainFitsSteeringAsAModulation.

Locally (MSVC 19.44, Release): ph_se2_test 18, ph_se2_train_test 11 and the
four PhTrain CLI tests pass. git clang-format (18) reports no changes.
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