Skip to content

ch6: the magnetometer chain returned a compass bearing, and five more - #126

Merged
qmohsu merged 1 commit into
mainfrom
claude/ch6-mag-chain-and-zupt
Aug 31, 2026
Merged

qmohsu merged 1 commit into
mainfrom
claude/ch6-mag-chain-and-zupt

Conversation

@qmohsu

@qmohsu qmohsu commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The magnetometer chain (B1)

mag_heading documents an ENU heading — 0 = East, increasing toward North — and pdr_step_update walks along [cos ψ, sin ψ] in exactly that convention. It returned 90° − ψ: a compass bearing, clockwise from North. A reflection, not an offset, so no constant could absorb it.

Nothing caught it because every synthetic magnetometer in the chapter was built to make the wrong formula come out right. All six — scripts/generate_ch6_env_sensors_dataset.py, scripts/generate_ch6_pdr_dataset.py, example_environment, example_pdr, example_comparison, and a notebook cell — wrote the reading as [cos(yaw), sin(yaw), 0], a field that co-rotates with the platform, which no magnetic field does. Each file was self-consistent, so nothing that compared a file against itself could see either half.

The chain is physical now, end to end. core.sensors.earth_field_map is a fixed map-frame field (45 µT, dip 32°, Hong Kong-ish); earth_field_body resolves it into an attitude using the transpose of the matrix strapdown integration uses; mag_heading inverts exactly that.

The truth harness

tests/core/sensors/test_mag_chain_recovers_enu_heading.py — 500 random attitudes, physical field → body → tilt compensation → heading → pdr_step_update. Max heading error 5.1e-14°. Red first at 178.1°, with level yaw -90.0 deg came back as 180.000 deg and facing 0.0 deg the walker stepped [0, 0.7], not [0.7, 0].

Three things the experiment settled that analysis kept getting wrong

tilt-compensation order max error over 500 attitudes
Ry(pitch) Rx(roll) — implemented 3.7e-14°
Rx(roll) Ry(pitch) — what the code carried 32.8° (median 6.0°)
Ry(−pitch) Rx(−roll) 177.9°
Rx(−roll) Ry(−pitch) 178.4°

Any two of these agree exactly whenever roll or pitch is zero, which is why every level test passed under both.

East declination subtracts in ENU. "True = magnetic + east declination" is the compass rule, for bearings clockwise from North; ENU heading runs counter-clockwise from East. FrameConvention.magnetic_north_heading owns the sign, so the same argument is correct for ENU and NED — and frame finally changes the answer, where before it was accepted and ignored.

The worked example in docs/ch6_frame_conventions.md claimed [20, 0, -40] gives ~90° while the code gave 0°. Both halves were wrong. It prints 90° now, for the opposite reason to the one the comment gave.

Blast radius: only magnetometer.txt, magnetometer_clean.txt and the magnetometer block of config.json moved in either ch6 dataset. Barometer, floor, attitude, position, time, accel and gyro files are byte-identical.

The magnetometer RMSE in example_pdr moved 1.6 m → 3.7 m. That is a different draw of the same distribution, not a degradation: over 24 seeds the heading-error RMS is 8.02° under the old chain and 8.05° under the new one, largest per-seed difference 0.41°.

The ZUPT detector never stopped firing (B2)

example_zupt ran detect_zupt_windowed at gamma=1000.0. Measured on its own data: TPR 95.0%, FPR 98.2%, accuracy 26.71% against a 26.67% stance base rate — the arithmetic signature of a constant predictor. The README transcript printed ZUPT detections: 97.0% of samples next to Stance time: 26.7%.

It looked like a success — 91.7% RMSE reduction — and that number is not achievable. An oracle detector handed the true stance mask scores 35.08 m RMSE; 9.22 m is better than the oracle. Clamping velocity to zero at every sample keeps a 61.6 m walk near the origin, which beats an IMU-only track that has exploded to 237 m.

γ is 17.0 now, chosen from a measured plateau:

γ TPR FPR accuracy
12.0 0.00% 0.00% 73.29%
13.5 81.88% 0.16% 95.04%
17.0 94.50% 0.18% 98.40%
21.5 94.62% 26.61% 79.07%
≥ 25 95.00% 98.18% 26.71%

Honest result: 66.0% RMSE reduction (110.49 → 37.56 m), final error 111.8% of distance. The oracle lands in the same place, so what remains belongs to the filter and the gait, not to detection — see Suspected new issues.

tests/ch6_dead_reckoning/test_zupt_detector_beats_the_stance_base_rate.py holds it, and is red against γ=1000: ZUPT detector at gamma=1000.0 scores 26.71% against a 26.71% stance base rate (TPR 95.00%, FPR 98.18%).

The strapdown "yaw error" of 359.6° was a branch cut (B3 + M5)

np.abs(att_est - att_true), unwrapped. Wrapped with wrap_angle_diff, the max yaw error is 1.20°.

The chapter attributed the whole 645 m of drift to gyro bias on the strength of that number. Measured: 639.1 m of the 645.6 m is vertical, where no gyro error reaches to first order. It is the accelerometer's z bias double-integrated — 0.5 × 0.1277 m/s² × (100 s)² = 638.5 m against 639.1 m measured, and the vertical log-log slope is 2.000 at every fit window. The 91.2 m of horizontal error is the gyro's share (g·|b_g|·t³/6 = 104 m). The example prints the attribution now.

Two "consumer-grade" IMUs (M2/M3/M4)

example_allan_variance wrote out its own noise table, and two of the three shared quantities disagreed with IMUNoiseParams.consumer_grade(): gyro ARW 0.5 deg/√hr against 0.1 (5×), and accel VRW 0.01 labelled m/s/√s against the same digits meaning m/s/√hr (√3600 = 60×). It is derived from IMUNoiseParams now.

Fixing the VRW is what makes the accelerometer's bias instability recoverable: its Allan floor arrives at τ ≈ 6.3 s instead of 22681 s, and it comes back within 20% where it was out by 5.9×. The gyro's ARW got harder to read, honestly so — with ARW 0.1 deg/√hr and B 10 deg/hr the white-noise region ends at τ = 0.8 s, so the conventional read at 1 s is past the shoulder. The example prints a per-region slope verdict for all four parameters rather than letting a printed number imply it was readable.

Its README block was the one ch6 transcript with no example-output marker, and had drifted into describing a run that no longer existed — an ARW of 0.0088 deg/√hr (navigation grade) under a header the example does not print. Marked and re-pasted from a real run. The conversion snippet below it fed that same 0.0088 into random_walk_to_rate_sample_std as though it were rad/√s: 5.04 °/s of per-sample gyro noise. It converts first now (0.045 °/s).

Claims measured rather than asserted (M7)

  • ch6_foot_zupt_walk: "50–150 m vs 0.3–0.7 m, ~100×" → 0.36 m vs 0.18 m, 1.9×. It is a 12 s, 14 m walk with zero accelerometer bias; the 100× figures belong to multi-minute bias-dominated walks.
  • ch6_strapdown_basic: σ_p(60s) ≈ 36 m read accel_noise_std as a noise density; it is a per-sample std, so 2.68 m per axis (Monte-Carlo 2.71 over 2000 trials; the shipped data integrated gives 4.40 m). "5× noise → 25× error" is 5× — σ_p is linear in the noise and super-linear only in time. The constant is 1/√3, not 1/√2 (measured: 2.71 vs 2.68 vs 3.29).
  • ch6_pdr_corridor_walk's heading-bias experiment measured final-point error on a closed loop, which is rotation-invariant: 0.29 m at 0° and 0.25 m at 20°, i.e. slightly down. Mean-along-path responds: 0.43 → 9.13 m. Its baseline was also 24 m because it used the deprecated step_length rather than Eq. (6.49).
  • env dataset: the mag-noise and disturbance tables were ranges with no run behind them; both are measured now, and the tilt-compensation ratio (1.4× on this ±5° dataset) is put beside the tilt sweep that earns the stronger claim (0.00° vs 25.6° at ±45°).

Figures — every changed PNG opened

  • comparison_error_cdf: log x. Wheel odometry and PDR were a single vertical line against the y axis.
  • wheel_odom_error: slip windows shaded, "With Slip" dashed over "No Slip", and the exact cancellation annotated — four equal slips on four perpendicular legs of a closed square sum to zero, so the tracks rejoin to 5 mm.
  • pdr_heading / pdr_dataset_results: estimates drawn on the truth's 2π branch. Ends a 360° offset and a solid blue slab where noise straddled the cut.
  • allan_*: markers are hollow and carry their region's slope where the region is absent. The accelerometer legend used to report an RRW that is not injected into the accelerometer at all.
  • zupt_detector_timeline: eight discrete bars aligned with true stance, not a solid slab.
  • pdr_dataset_results caption: the dataset is four 30 m legs, ~31 × 31 m, not "40m x 20m".

Equation index (--strict green)

(6.11)'s note had the lever-arm sign opposite to the code (code verified correct). (6.17) pointed at NavStateQPVPBias, whose body is shape validation → ZUPT_EKF.predict / compute_process_noise, with the missing F Jacobian stated. (6.33) pointed at the same dataclass and nothing implements it → files: []. (6.44) pointed at the two-threshold helper rather than the windowed statistic the equation defines. (6.49) pointed at a model returning 1.8×–2.1× the book's length over this chapter's cadence range. (6.56)/(6.57)/(6.58) descriptions did not match what calibration.py's own docstrings assign at ten sites. tests/core/sensors/test_zupt_statistic_and_process_model.py backs the two entries that had no real conformance test.

Also folded in: data/sim/ch6_foot_zupt_walk/README.md's executed block called bare plt.savefig(...) and wrote a stray SVG to the repo root on every docs-test run — two more in ch6_strapdown_basic. All three are plt.show() now, matching every other dataset README.

Suspected new issues (not fixed here)

  • The ZUPT-EKF is what limits example_zupt, not the detector. With a perfect detector the RMSE is 35.08 m against 37.56 m at γ=17 — the residual is the 10 mg accelerometer bias integrating freely for five seconds at a time. The gait is also not foot-mounted: stance is a 2 s standstill every 7 s rather than a stance phase every step. Worth its own investigation.
  • example_allan_variance cannot identify the gyro's ARW. IMUNoiseParams.consumer_grade() pairs an optimistic ARW (0.1 deg/√hr) with a consumer bias instability (10 deg/hr), which puts the shoulder at τ = 0.8 s. Real consumer MEMS pair ~0.3–1 deg/√hr with 10–100 deg/hr. Changing the shared spec would move several chapters' figures, so it is flagged rather than done.
  • environment_baro_altitude.svg was committed with its trailing whitespace stripped — every other SVG in ch6_dead_reckoning/figs/ keeps it, so that file was not what save_figure writes. Regenerating restores it; the PNG and PDF are unchanged, so the picture was always identical.

Test plan

All run on this branch, foreground:

  • tests/ch6_dead_reckoning — 85 passed
  • tests/core/sensors — 329 passed (tests/core overall 1482 passed, 6 skipped)
  • tests/docs — 880 passed, 1 skipped (includes test_readme_example_output.py -k ch6 7 passed and test_readme_code_blocks.py -k ch6 10 passed, each also run alone)
  • tests/test_datasets_reproduce_from_their_recipe.py — 43 passed
  • tests/test_repo_conventions.py tests/test_lint_debt_only_shrinks.py — 483 passed (I001 baseline 59 → 58)
  • tests/test_repo_validators.py tests/test_semantic_api_debt_only_shrinks.py — 4 passed
  • tests/test_examples_answer_help.py test_examples_import_this_checkout.py test_example_console_encoding.py test_every_figure_has_a_demo_behind_it.py — 358 passed, 8 skipped
  • Every ch6 figure regenerated in-repo and opened
  • plot_error_cdf's new xscale defaults to "linear": ch3 and ch5 figures regenerate byte-identically
  • Black run verified not to move any figure — all ch6 PNGs byte-identical across it

🤖 Generated with Claude Code

…re ch6 defects

## The magnetometer chain (the big one)

`mag_heading` documents an ENU heading -- 0 = East, increasing toward North --
and `pdr_step_update` walks along `[cos psi, sin psi]` in exactly that
convention. It returned `90 deg - psi`: a compass bearing, clockwise from
North. A reflection, not an offset, so no constant could absorb it.

Nothing caught it because every synthetic magnetometer in the chapter was
built to make the wrong formula come out right. All six of them -- two
generators, three examples and a notebook cell -- wrote the reading as
`[cos(yaw), sin(yaw), 0]`, a field that co-rotates with the platform, which no
magnetic field does. Each file was self-consistent, so nothing comparing a
file against itself could see either half.

The chain is physical now, end to end: `earth_field_map` is a fixed map-frame
field (45 uT, dip 32 deg -- Hong Kong-ish), `earth_field_body` resolves it into
an attitude with the transpose of the matrix strapdown integration uses, and
`mag_heading` inverts exactly that.
`tests/core/sensors/test_mag_chain_recovers_enu_heading.py` is the truth
harness: 500 random attitudes, field -> body -> tilt compensation -> heading ->
pdr_step_update, max error 5.1e-14 deg. Red first at 178.1 deg.

Three things it settled that analysis kept getting wrong:

- Eq. (6.52)'s rotation order is `Ry(pitch) Rx(roll)`, the inverse of the
  roll/pitch part of `C_B^M = Rz Ry Rx`. Three independent derivations gave
  three different orders. Measured over the same 500 attitudes: implemented
  3.7e-14 deg, `Rx(roll) Ry(pitch)` (what the code carried) 32.8 deg, the two
  negated orders 177.9 and 178.4 deg. Any two agree exactly whenever roll or
  pitch is zero, which is why every level test passed under both.
- East declination SUBTRACTS in ENU. "True = magnetic + east declination" is
  the compass rule; ENU heading runs the other way.
  `FrameConvention.magnetic_north_heading` owns the sign, and `frame` finally
  changes the answer where before it was accepted and ignored.
- `docs/ch6_frame_conventions.md` claimed `[20, 0, -40]` gives ~90 deg while
  the code gave 0. Both halves were wrong; it prints 90 now, for the opposite
  reason to the one the comment gave.

Only `magnetometer*.txt` and the magnetometer block of `config.json` moved in
either ch6 dataset. Barometer, floor, attitude, position, time, accel and gyro
files are byte-identical.

## The ZUPT detector never stopped firing

`example_zupt` ran at `gamma=1000.0`, in the always-fires region: ZUPT on 98.2%
of *swing* samples, 26.7% accuracy -- exactly the stance base rate, the
signature of a constant predictor. The transcript printed
"ZUPT detections: 97.0%" next to "Stance time: 26.7%" and nobody read the two
lines together.

It looked like a success: 91.7% RMSE reduction. That number is not achievable.
An ORACLE detector handed the true stance mask scores 35.08 m RMSE, and 9.22 m
is better than the oracle -- clamping velocity to zero keeps a 61.6 m walk near
the origin. gamma is 17.0 now, from a measured plateau (98.4% accuracy for
16-20, against 26.7% above 25 and 73.3% below 12). Honest result: 66% RMSE
reduction, 37.56 m. The oracle lands in the same place, so what remains is the
filter and the gait, not detection.

## The strapdown "yaw error" of 359.6 deg was a branch cut

`np.abs(att_est - att_true)` unwrapped. Wrapped, the yaw error is 1.20 deg.
The chapter attributed the whole 645 m of drift to gyro bias on the strength of
that number; measured, 639 m of the 645 m is VERTICAL, where no gyro error
reaches to first order. It is the accelerometer z bias double-integrated:
0.5 * 0.1277 * 100^2 = 638.5 m against 639.1 m measured, log-log slope 2.000.
The 91 m of horizontal error is the gyro's share.

## Two "consumer-grade" IMUs, and an unpinned transcript

`example_allan_variance` wrote out its own noise table: gyro ARW 0.5 deg/sqrt(hr)
against `IMUNoiseParams.consumer_grade()`'s 0.1 (5x), and accel VRW "0.01
m/s/sqrt(s)" against the same digits meaning m/s/sqrt(hr) (sqrt(3600) = 60x).
Derived from `IMUNoiseParams` now. Fixing the VRW is what makes the
accelerometer's bias instability recoverable at all -- its Allan floor arrives
at tau = 6.3 s instead of 22681 s, and it comes back within 20% where it was
out by 5.9x.

Its README block was the one ch6 transcript with no `example-output` marker.
It claimed an ARW of 0.0088 deg/sqrt(hr) -- navigation grade -- under a header
the example does not print. Marked and re-pasted from a real run. The
conversion snippet below it fed that same 0.0088 into
`random_walk_to_rate_sample_std` as if it were rad/sqrt(s): 5.04 deg/s of
per-sample gyro noise. It converts first now.

## Claims measured rather than asserted

- ch6_foot_zupt_walk: "50-150 m vs 0.3-0.7 m, ~100x" -> 0.36 m vs 0.18 m,
  1.9x. It is a 12 s, 14 m walk with zero accelerometer bias.
- ch6_strapdown_basic: `sigma_p(60s) ~ 36 m` read `accel_noise_std` as a noise
  density; it is a per-sample std, so 2.68 m per axis (Monte-Carlo 2.71,
  shipped data 4.40 m). "5x noise -> 25x error" is 5x: sigma_p is linear in
  the noise. The constant is 1/sqrt(3), not 1/sqrt(2).
- ch6_pdr_corridor_walk's heading-bias experiment measured final-point error on
  a CLOSED loop, which is rotation-invariant: 0.29 m at 0 deg, 0.25 m at 20.
  Mean-along-path responds: 0.43 -> 9.13 m.
- env dataset: the noise sweep and disturbance tables were ranges with no run
  behind them; both are measured now, and the tilt-compensation "1.4x" is put
  next to the tilt sweep that earns the stronger claim.

## Figures, all opened after regeneration

Error CDF on a log x-axis (two methods were one line against the y axis);
wheel-odometry slip windows shaded with the exact cancellation explained (four
equal slips on four perpendicular legs sum to zero); PDR heading panels drawn
on the truth's branch, ending a 360 deg offset and a solid noise slab; Allan
markers hollow and slope-annotated where the region is absent -- the
accelerometer legend used to report an RRW that is not injected into it at all;
the ZUPT detection panel is eight bars instead of a slab.

## Equation index

(6.11)'s note had the lever-arm sign opposite to the code. (6.17) pointed at a
dataclass whose body is shape validation; (6.33) at the same one, and nothing
implements it -- `files: []` now. (6.44) pointed at the two-threshold helper
rather than Eq. (6.44)'s windowed statistic. (6.49) pointed at a model that is
1.8x to 2.1x the book's over this chapter's cadence range. (6.56)/(6.57)/(6.58)
descriptions did not match what calibration.py's own docstrings assign. New
conformance tests back the two that had none.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qmohsu
qmohsu merged commit d5fc919 into main Aug 31, 2026
1 check passed
@qmohsu
qmohsu deleted the claude/ch6-mag-chain-and-zupt branch August 31, 2026 03:41
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