Skip to content

Hold RotorLag inside the rotor's thrust limits (#292) - #352

Open
maskjelly wants to merge 1 commit into
kmolan:mainfrom
maskjelly:fix/rotor-lag-thrust-limits
Open

maskjelly wants to merge 1 commit into
kmolan:mainfrom
maskjelly:fix/rotor-lag-thrust-limits

Conversation

@maskjelly

Copy link
Copy Markdown

Closes #292.

RotorLag carried no limits, so a caller stepping it with an unclamped command left the state representing a thrust the rotor could not produce — the mixer's clamp, undone one stage downstream.

The API. The limits are optional and a model starts without any, so an unbounded lag follows its command exactly as before:

let rotors = RotorLag::<4, f64>::new(0.02, 0.001)?
    .try_with_thrust_limits(0.0, 5.0)?;   // the same pair the mixer was built with

try_with_thrust_limits validates the pair the way MultirotorMixer::new does (reusing PlantError::NonFinite and PlantError::InvalidThrustLimits), and minimum_thrust()/maximum_thrust() read them back, NEG_INFINITY/INFINITY when unset. From then on stepped, stepped_over and with_thrusts hold the state inside them; each of the three goes through one private clamp helper.

Decisions worth a look:

  • The clamp compares instead of using Numeric::min/max. The floating-point min/max return the non-NaN operand, so at unbounded limits a NaN command would come back as ±∞ — quietly changing the documented "a command that is not finite comes back not finite" policy. Comparisons leave a NaN alone, and the existing non-finite test still passes.
  • reset stays the disarmed all-zero state, not clamped to a minimum above zero: new starts there too, and a stopped rotor is not giving thrust. The next step climbs back inside the limits. Documented on both items.
  • rate is not clamped — it is a derivative rather than the state — and its doc now says so.

Tests (tests/suite/plant/rotor_lag.rs, 9 new): a command above the maximum settles exactly on it and never exceeds it on any tick (and vice versa for the minimum), the same for stepped_over, the refused limits, with_thrusts clamped on the way in, the disarmed reset, limits far outside the command leaving the lag identical to the unbounded one tick by tick, and the issue's end-to-end case — a limited lag fed the mixer's clamped rotor_thrusts produces the mixer's wrench, and fed the raw command it lands exactly on the mixer's clamped thrusts.

Evidence. With the clamp temporarily neutered the six limit-dependent tests fail (a rotor must never give more than its maximum, left […30.0] vs right […5.0]); restored, the file is 26/26. cargo test -p multicalc (451 doctests included), --features alloc (539), --all-features --doc (539), -p multicalc-robot-model, cargo clippy -p multicalc --all-targets --features alloc -- -D warnings, and cargo fmt --all --check are all green.

Not done. demos/ left alone: the lag showcases do not discuss limits and the limits demo does not show RotorLag. cargo test --workspace cannot build tools/embedded-smoke on the host (89 × cannot find hprintln) — pre-existing, verified by stashing this change.

`RotorLag` carried no limits, so a caller stepping it with an unclamped
command left the state representing a thrust the rotor could not produce —
the mixer's clamp, undone one stage downstream.

The limits are optional and a model starts without any, so an unbounded
lag follows its command exactly as before. `try_with_thrust_limits` takes
the same pair the mixer was built with (validating them the same way,
reusing `NonFinite` and `InvalidThrustLimits`), and from then on
`stepped`, `stepped_over` and `with_thrusts` hold the state inside them.

The clamp compares rather than using `Numeric::min`/`max`: the
floating-point `min`/`max` return the non-NaN operand, which would turn a
NaN command into an infinity at unbounded limits and quietly change the
documented "non-finite in, non-finite out" policy. Comparisons leave a NaN
alone.

`reset` stays the disarmed all-zero state rather than being held to a
minimum above zero, and says so; `rate` is a derivative rather than the
state and is left unclamped, also said so. The plant tutorial now builds
the lag with the mixer's limits, and the error list names the new
builder.

The suite pins the settle-on-the-limit behavior for both step paths, the
refused limits, the with_thrusts clamp, the disarmed reset, and the
`wrench` agreement between the limited lag and the mixer's own clamped
thrusts.
@maskjelly
maskjelly requested a review from kmolan as a code owner September 16, 2026 20:55

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the first PR! 🎉 You'll get a first review within 48 h. CI runs the 5-target matrix — a budget-gate failure isn't your fault, see ci/README.md.

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.

Hold RotorLag inside the rotor's thrust limits

1 participant