Skip to content

fix(rudder): transition guard rollover, dropped irons params, and shipped-path test coverage - #65

Open
georgesleen wants to merge 3 commits into
mainfrom
fix/rudder-control-model-latent-defects
Open

fix(rudder): transition guard rollover, dropped irons params, and shipped-path test coverage#65
georgesleen wants to merge 3 commits into
mainfrom
fix/rudder-control-model-latent-defects

Conversation

@georgesleen

Copy link
Copy Markdown
Contributor

Follow-up to the module merge in #62. While checking whether Emma's refactor of the rudder control model is functionally equivalent to what rudder-working-branch was running, I found the refactor is equivalent on the shipped path, but a few defects came along with the merge. This fixes the ones that are safe to fix and adds the missing coverage.

Context: the refactor itself is sound

The firmware compiles with STRAIGHT_ONLY, so runPID only ever reaches straightLine() -> getRudderAngle(). Those functions are byte-identical between b07fea8 and main, and Estimator_UpdateController is a faithful transliteration of the old updateAverages.

Verified by differential test: 200 randomised trajectories, 10000 samples, identical stimulus into both builds. Patch two defects into the old code and the traces match exactly, 0 of 200 runs diverging. The refactor also repaired three real bugs: a duplicated integral increment, uninitialised ControllerState, and a state machine that was dead because runPID called returnState(error) and discarded the result.

One thing needs a human decision, not a code change: the May/June dryland gains were tuned against the build whose integral ran 1.5x high, so Ki is effectively different now. Worth re-checking before the next on-water test.

What this PR changes

1. Transition guard rollover. The guard uses a signed difference to survive tick rollover, but an unset guard is stored as 0, and the signed difference reads 0 as still pending once uptime passes 2^31 ms (24.9 days). Every transition would then be blocked for the rest of the run. Not reachable under STRAIGHT_ONLY (from == to short-circuits first); becomes reachable the moment the full model is enabled.

2. Dropped irons parameters. The merge replaced upwindIronsAngle/downwindIronsAngle with upwindIronsRange/downwindIronsRange but left the originals out of the initialiser, so they silently became 0. irons() still reads them. Restored to 30/150.

3. Missing else in irons(). The port branch had no else, so it overwrote the starboard assignment on every call and the starboard case could never take effect.

Fixes 2 and 3 are in the escape block of irons(), which does not currently execute. irons() runs only in state IRONS, and the state machine leaves IRONS in the same cycle isInIrons() goes false, so the if (!isInIrons()) gate is never true when irons() runs. Brute force over 20000 trials biased into the irons regime: 87242 calls, escape block executed 0 times. These are corrections to code that must be right before that path is revived, not live behaviour changes.

4. Test coverage for the path that actually ships. STRAIGHT_ONLY and TUNING_MODE are wrapped in #ifndef RUDDER_TEST_BUILD, and the module's existing tests define RUDDER_TEST_BUILD. So everything they exercise is compiled out of the firmware, straightLine() had no coverage at all, and none of it ran in CI. That is why a 1.5x integral error survived to a merge review.

Two new tests in firmware/tests, which CI already runs:

  • rudder_straight_test builds without RUDDER_TEST_BUILD and pins the exact per-cycle integral update.
  • rudder_state_machine_test defines RUDDER_TEST_BUILD and covers the transition guards.

Both assertions were mutation-checked: reintroducing the duplicated integral line fails the first, reverting the guard fix fails the second.

Supporting changes: TEST_USE_FAKE_TICK + common/fake_tick.c give tests a settable HAL_GetTick (the stub returns 0 and cannot drive a PID); the default stays the existing inline stub so other tests are untouched. RUDDER_PRINT replaces the two unconditional printf calls in the PID hot path, expanding to printf unless RUDDER_QUIET is defined, so firmware output is unchanged. The two rudder targets drop -Werror because the module carries pre-existing warnings.

Deliberately not fixed

The irons() escape block being unreachable, and ironsState.fixedHeading being write-only, are the same underlying problem: the IRONS state has no working recovery behaviour. Fixing it means deciding what the boat should actually do to sail out of irons, and how long to hold the state while it does. That is a control-design call for whoever owns the model, so I have not invented steering logic for it.

Also untouched: SailingSM_Update and the whole SailingMode/EstimatedBoatState machine, plus updateStateEstimate and applyEstimateToController, are never called by firmware or tests (~400 lines). Left in place in case they are staged work.

Verification

  • nix develop .#ci --command make -C firmware/tests test passes, all 8 targets.
  • All four module sources cross-compile clean for cortex-m33 in the firmware configuration (STRAIGHT_ONLY on, prints on).
  • No behaviour change on the shipped path: the only edits reachable under STRAIGHT_ONLY are the printf gating, which is a no-op unless RUDDER_QUIET is defined.

The guard check uses a signed difference so it survives tick rollover. An
unset guard is stored as 0, and the signed difference reports 0 as still
pending once the tick counter passes 2^31 ms, which is 24.9 days of uptime.
Every state transition would then be blocked for the rest of the run.

Not reachable while STRAIGHT_ONLY is defined, because updateStateMachine
returns early when the current and next states match. It becomes reachable
as soon as the full model is enabled.
The module merge replaced upwindIronsAngle and downwindIronsAngle with
upwindIronsRange and downwindIronsRange, but left the two original fields
out of the initialiser, so they defaulted to 0. irons() still reads them.
Restore the previous 30 and 150 values.

In the same function, the port branch was written without an else, so it
overwrote the starboard assignment on every call and the starboard case
could never take effect.

Both sites sit in the escape block of irons(), which does not currently
execute: irons() runs only in state IRONS, and the state machine leaves
IRONS in the same cycle isInIrons() goes false, so the "if (!isInIrons())"
gate is never true when irons() runs. These are corrections to code that
must be right before that path is revived, not live behaviour changes.
STRAIGHT_ONLY and TUNING_MODE are wrapped in "#ifndef RUDDER_TEST_BUILD",
and the module's existing tests define RUDDER_TEST_BUILD. Everything those
tests exercise is compiled out of the firmware, and straightLine(), the only
path the boat runs, had no coverage. The module's tests were also not wired
into CI.

Add two host tests to firmware/tests, which CI already runs:

- rudder_straight_test builds without RUDDER_TEST_BUILD, so it tests the
  firmware configuration. It pins the exact per-cycle integral update. A
  duplicated integral increment shipped on rudder-working-branch and made
  the integral run 1.5x high, and the PID gains were tuned against it. Both
  assertions were checked by reintroducing the defect.
- rudder_state_machine_test defines RUDDER_TEST_BUILD to switch
  STRAIGHT_ONLY off and covers the transition guards.

Supporting changes:

- HAL_GetTick in the stub HAL returns 0 and cannot drive a PID. Tests that
  need time now define TEST_USE_FAKE_TICK and link common/fake_tick.c. The
  default stays the existing inline stub, so other tests are unaffected.
- RUDDER_PRINT replaces the two unconditional printf calls in the PID hot
  path. It expands to printf unless RUDDER_QUIET is defined, so firmware
  output is unchanged and only the tests are quiet.
- The two rudder targets drop -Werror. The module carries pre-existing
  unused-variable and const-qualifier warnings; cleaning those up is
  separate work.
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