Skip to content

Queue 1: fix ESKF vertical-channel divergence - #285

Open
jbrodovsky wants to merge 4 commits into
mainfrom
v1/01-eskf-fix
Open

Queue 1: fix ESKF vertical-channel divergence#285
jbrodovsky wants to merge 4 commits into
mainfrom
v1/01-eskf-fix

Conversation

@jbrodovsky

Copy link
Copy Markdown
Owner
Branch v1/01-eskf-fix
Base main
Issues #266
Queue position 1

Why

Three confirmed defects: position error is radians in F and H but metres in
inject_error_state; the velocity-attitude block of F uses a global attitude-error convention
while attitude propagation, gyro-bias coupling and injection all use local; and a scalar 1e-9
covariance jitter is added to all 15 diagonal entries regardless of units.

Acceptance criteria

  • ESKF altitude bounded on test_data.csv with full GNSS aiding
  • ESKF altitude bounded under degraded GNSS
  • All three #[ignore]d ESKF tests re-enabled
  • Thresholds restated as physical bounds, not golden numbers
  • Finite-difference test of error_state_transition_jacobian with non-identity C_bn
  • Regression test: filter insensitive to ~1e-12 input perturbation
  • apply_eskf_correction and inject_error_state unified into one function
  • RBPF unaffected (it shares apply_eskf_correction)
  • test_eskf_stability_high_dynamics and test_filter_comparison pass on macOS and Windows

Closes #266

Supersedes #270, which GitHub auto-closed when queue position 0's branch was deleted on merge and
refuses to reopen because this branch was force-pushed afterwards. Same branch, same work.

Branches off main (queue position 0 is merged).

Queue: docs/V1_QUEUE.md | Board: https://github.com/users/jbrodovsky/projects/7

jbrodovsky and others added 2 commits September 4, 2026 16:06
Placeholder so the stacked PR has a diff. Replaced by the implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merging queue position 0 with --delete-branch auto-closed the PR stacked on
it (#270), and GitHub then refused to reopen it because the head branch had
been force-pushed by the stack rebase. The PR had to be recreated as #285.

Record the order that avoids it: merge without deleting, retarget the child
PR to main, rebase, force-push, and only then delete the merged branch. Also
note that rebase-merge rewrites SHAs even on a fast-forward, which is why the
rebase needs --onto against a recorded tip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nsistent

Three defects, all confirmed by reading the code and each measured
independently on core/tests/test_data.csv with full GNSS aiding.

1. Position error was radians in F and H but metres on injection.

   error_state_transition_jacobian produces a radian position-error rate
   (f[(0,3)] = dt / r_n), gps_position_jacobian is the identity against a
   radian-valued measurement, and linearize::apply_eskf_correction adds
   straight into state.latitude. But inject_error_state divided by the
   principal radii, treating delta_p as metres, so the horizontal correction
   actually applied was ~1/6.4e6 of what the gain computed.

   The horizontal channel was therefore open loop. The lat/lon innovation
   never nulled and the filter drove that persistent residual into velocity,
   tilt and accelerometer bias instead.

   Horizontal rms: 1734.41 m -> 24.31 m.

2. F mixed two attitude-error conventions.

   The velocity/attitude block used -[C_b^n f^b]_x dt, the GLOBAL form, while
   attitude propagation (-[omega^b]_x), gyro-bias coupling (bare identity)
   and injection (q_nom (x) dq, right-multiplied) are all LOCAL. The two
   differ by a rotation, so with non-zero heading the tilt-to-velocity
   feedback landed on the wrong axes. Now -C_b^n [f^b]_x dt throughout.

   Altitude peak: 627.51 m -> 379.57 m, and this is what let
   test_eskf_closed_loop_on_real_data pass again.

3. Covariance jitter was scale-blind.

   A flat 1e-9 was added to all 15 diagonal entries after both predict and
   update. In rad^2 that is ~(200 m)^2 of bogus horizontal position variance
   per step against R_horiz ~ 5.5e-13; for velocity in m^2/s^2 it is a
   rounding error. It also silently overwrote any deliberately small
   covariance -- freezing a state at 1e-12 variance had no effect at all,
   which is how it was found. Replaced with a relative jitter scaled by each
   state's own variance, floored by its process noise.

Tests. Three that would have caught this, each verified to fail when its
fix is reverted:

  - linearize: finite-difference check of F's velocity/attitude block against
    numerical differentiation of the nominal propagation, with a non-identity
    C_b^n (the conventions agree at identity attitude, which is why the
    existing full-state Jacobian tests missed it). Reverted, the analytic
    block reads -0 where the true coupling is 0.084.
  - kalman: injection of a known error state must move latitude by the exact
    radian amount, at a latitude where a stray 1/(r_e cos lat) cannot cancel.
  - kalman: a 1e-12 relative input perturbation must not move altitude by a
    millimetre. #266 reported a 1e-14 perturbation growing to 1.5e8 m.

Thresholds. test_eskf_closed_loop_on_real_data is re-enabled, and the golden
numbers in test_eskf_stability_high_dynamics (1905.0/2494.0) and
test_filter_comparison (1905.0) are replaced with physical bounds. Those
numbers were pinned so tightly to the broken behaviour that macOS and Windows
failed them at 2121.97 m on floating-point code generation alone; the ESKF now
runs at 24 m rms and is held to the same standard as the EKF.

Not fixed here: the vertical channel. Altitude rms is 129.54 m against the
UKF's 2.82 m on the same data, vertical velocity error is 44 m/s against
0.44 m/s, and the bias states still reach 9 m/s^2 and 6 rad/s. The two
remaining ESKF tests stay #[ignore]d and the altitude thresholds in the
re-enabled test are labelled provisional. Diagnosis and leads are in #286.

Refs #266, #286

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jbrodovsky
jbrodovsky marked this pull request as ready for review September 4, 2026 20:45
@jbrodovsky

Copy link
Copy Markdown
Owner Author

Status: three of four defects fixed; the vertical channel is not

Marking this ready for review. It does not close #266 outright -- #266's acceptance criteria
require a bounded vertical channel and all three ESKF tests re-enabled, and only one of the three is
back. The remainder is diagnosed in #286.

What changed, measured independently

horizontal rms altitude rms altitude max
before 1734.41 m 134.58 m 509.47 m
+ position units (defect 1) 24.31 m 134.85 m 627.51 m
+ attitude convention (defect 2) 24.05 m 127.58 m 379.57 m
+ scale-aware jitter (defect 3) 24.23 m 129.54 m 436.39 m
UKF, same data 23.62 m 2.82 m 12.08 m

The horizontal channel went from 77x worse than the UKF to matching it. Defect 1 is the whole of
that: dividing by the principal radii on injection made every horizontal correction ~1/6.4e6 of what
the gain computed, so the loop was open.

Tests

Three new tests, each verified to fail when its own fix is reverted:

  • Finite-difference check of F's velocity/attitude block with a non-identity C_b^n. The two
    conventions agree at identity attitude, which is exactly why the existing full-state Jacobian
    tests missed this. Reverted, the analytic block reads -0 where the true coupling is 0.084.
  • Injection of a known error state must move latitude by the exact radian amount, at a latitude
    where a stray 1/(r_e cos lat) cannot coincidentally cancel.
  • A 1e-12 relative input perturbation must not move altitude by a millimetre.

Integration suite: 13 passing (was 12), 3 ignored (was 4). 240 unit + 63 doc tests pass, clippy
and fmt clean.

Golden numbers removed

test_eskf_stability_high_dynamics (1905.0 / 2494.0) and test_filter_comparison (1905.0) were
pinned to the broken behaviour so tightly that macOS and Windows failed them at 2121.97 m on
floating-point code generation alone. Both now carry physical bounds, which should clear two of the
three cross-platform CI failures on main.

What is left

Altitude rms 129.54 m vs the UKF's 2.82 m; vertical velocity error 44 m/s vs 0.44 m/s; bias states
reaching 9 m/s² and 6 rad/s, which are physically impossible for this IMU. test_eskf_with_degraded_gnss
and test_eskf_output_length_matches_input stay ignored, and the altitude thresholds in the
re-enabled test are explicitly labelled provisional rather than physical.

#286 carries the instrumentation traces, what has been ruled out (the frame flag, the H matrix, the
jitter, all three defects here), and five leads -- the most likely being that update copies a
9-state Jacobian whose attitude columns are derivatives w.r.t. Euler angles into an error state
whose attitude columns are a body-frame rotation vector.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Several integration-test assertion messages are incorrect/misleading (UKF/EKF labeled as ESKF) and the PR’s stated acceptance criteria still conflict with remaining ignored ESKF tests due to unresolved vertical divergence.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR targets the ESKF’s vertical-channel divergence instability (#266) by correcting unit conventions and attitude-error conventions in the error-state linearization and injection, and by improving covariance regularization. It also updates the v1 queue documentation and adds/adjusts tests to prevent regressions and reduce “golden number” thresholds.

Changes:

  • Fix ESKF state/error-state consistency: inject lat/lon error in radians (not metres) and use a body-frame attitude-error convention in the F velocity/attitude block.
  • Replace absolute covariance “jitter” with per-state relative regularization scaled by each state’s variance / process-noise floor.
  • Add regression tests (finite-difference Jacobian convention check; tiny-perturbation sensitivity check) and revise integration-test bounds/messages.
File summaries
File Description
docs/V1_QUEUE.md Documents safer spine-PR merge/rebase ordering to avoid GitHub auto-closing/reopen issues in stacked PRs.
docs/queue/01-eskf-fix.md Adds queue entry capturing motivation and acceptance criteria for the ESKF fix work.
core/tests/integration_tests.rs Re-enables/adjusts ESKF assertions and thresholds; updates ignore reasons; (also introduces several incorrect assert messages).
core/src/linearize.rs Fixes F velocity/attitude block to match local/body-frame attitude-error convention; adds finite-difference regression tests.
core/src/kalman.rs Fixes ESKF position injection units (radians for lat/lon) and introduces variance-scaled covariance regularization; adds ESKF regression tests.
Review details

Suppressed comments (9)

core/tests/integration_tests.rs:668

  • This is the UKF test (test_ukf_closed_loop_on_real_data), but the assertion message says "ESKF".
        "ESKF maximum horizontal error should be less than {:.2}m, got {:.2}m",

core/tests/integration_tests.rs:674

  • This is the UKF test, but the altitude assertion message says "ESKF" and mentions a provisional vertical bound / #286, which doesn't apply here.
        "ESKF RMS altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:680

  • This is the UKF test, but the altitude assertion message says "ESKF" and includes the provisional/#286 note.
        "ESKF maximum altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:997

  • This is the EKF test, but the assertion message says "ESKF".
        "ESKF maximum horizontal error should be less than {:.2}m, got {:.2}m",

core/tests/integration_tests.rs:1003

  • This is the EKF test, but the altitude assertion message says "ESKF" and mentions provisional bounds/#286, which doesn't apply here.
        "ESKF RMS altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:1009

  • This is the EKF test, but the altitude assertion message says "ESKF" and includes the provisional/#286 note.
        "ESKF maximum altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:1140

  • This is the degraded-GNSS EKF test, but the altitude assertion message says "ESKF" and mentions provisional bounds/#286.
        "ESKF RMS altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:1146

  • This is the degraded-GNSS EKF test, but the altitude assertion message says "ESKF" and includes the provisional/#286 note.
        "ESKF maximum altitude error should be less than {:.2}m (provisional, see #286), got {:.2}m",

core/tests/integration_tests.rs:1134

  • This is the degraded-GNSS EKF test, but the assertion message says "ESKF".
        "ESKF maximum horizontal error should be less than {:.2}m, got {:.2}m",
  • Files reviewed: 5/5 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

/// This test simulates degraded GNSS conditions with reduced update rate (5s intervals).
#[test]
#[ignore = "ESKF with degraded GNSS has altitude divergence and health monitor aborts due to out of range"]
#[ignore = "ESKF vertical channel still diverges under degraded GNSS; horizontal loop fixed in #266, vertical tracked by #286"]
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
"ESKF RMS horizontal error should be less than {:.2}m, got {:.2}m",
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
"ESKF RMS horizontal error should be less than {:.2}m, got {:.2}m",
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
"ESKF RMS horizontal error should be less than {:.2}m, got {:.2}m",
@jbrodovsky

Copy link
Copy Markdown
Owner Author

CI: every ESKF and RBPF failure is cleared

job main @ 3259edb this PR
ubuntu-latest, no-default-features pass pass
windows-latest, no-default-features fail pass
macos-latest, no-default-features fail fail (#284 only)
Workspace (all features) fail fail (#284 only)

The three test failures that were red on main across macOS and Windows are gone:

The single remaining failure on the two red jobs is
sim::tests::test_execution_monitor_successful_execution_with_progress, which is #284 -- the test
that asserts an upper bound on thread::sleep with a 5x margin. It is independent of this PR:
a docs-only branch off main (#279) reproduces it with every .rs file byte-identical.

Fixing #284 would make this the first fully green CI on the repo in a while, but it is unrelated to
the ESKF and belongs in its own change.

…k in F

Two further errors in error_state_transition_jacobian, both found by
numerically differentiating forward() and comparing, rather than by reading.

1. The attitude block was assigned over the identity instead of subtracted
   from it.

       f[(6 + i, 6 + j)] = -omega_skew[(i, j)] * dt;   // was
       f[(6 + i, 6 + j)] -= omega_skew[(i, j)] * dt;   // now

   F is initialised to the identity and the block is I - [omega^b]_x dt. A
   skew-symmetric matrix has a zero diagonal, so the assignment silently set
   f[(6,6)], f[(7,7)] and f[(8,8)] to zero. The attitude error covariance was
   therefore annihilated on every propagation step: the filter behaved as
   though attitude were perfectly known after each predict, never corrected
   tilt, and left gyro bias -- observable only through tilt -- unconstrained.

   The full-state state_transition_jacobian already used += on the same block
   (linearize.rs:233), so the error-state copy was the outlier.

2. The whole d(vdot)/d(p) block was missing.

   earth::gravity is g0(lat) - 3.08e-6 h, so both derivatives exist:
   dg/dh = -3.08e-6 s^-2 and dg/dlat ~ 0.0519 m/s^2/rad at 45 degrees. Finite
   differences show both clearly and flat across four orders of magnitude of
   step size. The comment that stood here argued the gravity-gradient term
   should be dropped because in ENU it yields growing eigenvalues. That
   instability is real, but it belongs to the system, not the linearisation:
   omitting a term the nominal propagation actually applies makes F disagree
   with forward(), so the covariance stops describing the true error dynamics
   and the filter cannot attribute an altitude innovation to vertical-velocity
   error. Added with the frame sign taken from state.is_enu, plus a
   gravity_latitude_gradient helper deriving the Somigliana derivative.

Tests. Two more, both of which fail if their fix is reverted:

  - every diagonal entry of F must be ~1, with a non-zero angular rate so the
    annihilation is visible (it is not, at omega = 0)
  - F must agree with a finite difference of forward() across the position,
    velocity and attitude columns, at a tolerance above the legitimate O(dt^2)
    omissions and far below any first-order structural error

Bias columns are excluded from the second test for now: forward() stores
attitude through Rotation3::from_matrix, an iterative orthonormalising
projection whose own derivative is not unity, and differencing through it
reports d(theta)/d(b_g) as ~1.5x dt instead of dt. Recorded in #286.

Effect on the vertical channel is small -- altitude rms 129.54 -> 119.30 m
against the UKF's 2.82 m -- so neither of these is the dominant cause. What
they do establish, together with the measurement that the ESKF at the UKF's
own Q and P0 scores 286.61 m against the UKF's 2.82 m, is that the remaining
fault is structural rather than tuning. Horizontal is now 23.82 m rms, at
parity with the UKF's 23.62 m.

Refs #286

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

[v1.0 blocker] ESKF vertical channel diverges: altitude reaches 1.5e8 m with full GNSS aiding

2 participants