Repository navigation
ch6: the magnetometer chain returned a compass bearing, and five more - #126
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The magnetometer chain (B1)
mag_headingdocuments an ENU heading — 0 = East, increasing toward North — andpdr_step_updatewalks 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_mapis a fixed map-frame field (45 µT, dip 32°, Hong Kong-ish);earth_field_bodyresolves it into an attitude using the transpose of the matrix strapdown integration uses;mag_headinginverts 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°, withlevel yaw -90.0 deg came back as 180.000 degandfacing 0.0 deg the walker stepped [0, 0.7], not [0.7, 0].Three things the experiment settled that analysis kept getting wrong
Ry(pitch) Rx(roll)— implementedRx(roll) Ry(pitch)— what the code carriedRy(−pitch) Rx(−roll)Rx(−roll) Ry(−pitch)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_headingowns the sign, so the same argument is correct for ENU and NED — andframefinally changes the answer, where before it was accepted and ignored.The worked example in
docs/ch6_frame_conventions.mdclaimed[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.txtand the magnetometer block ofconfig.jsonmoved in either ch6 dataset. Barometer, floor, attitude, position, time, accel and gyro files are byte-identical.The magnetometer RMSE in
example_pdrmoved 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_zuptrandetect_zupt_windowedatgamma=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 printedZUPT detections: 97.0% of samplesnext toStance 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:
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.pyholds 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 withwrap_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 magainst 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_variancewrote out its own noise table, and two of the three shared quantities disagreed withIMUNoiseParams.consumer_grade(): gyro ARW 0.5 deg/√hr against 0.1 (5×), and accel VRW0.01labelled m/s/√s against the same digits meaning m/s/√hr (√3600 = 60×). It is derived fromIMUNoiseParamsnow.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-outputmarker, 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 same0.0088intorandom_walk_to_rate_sample_stdas 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)
σ_p(60s) ≈ 36 mreadaccel_noise_stdas 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).step_lengthrather than Eq. (6.49).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_resultscaption: the dataset is four 30 m legs, ~31 × 31 m, not "40m x 20m".Equation index (
--strictgreen)(6.11)'s note had the lever-arm sign opposite to the code (code verified correct).(6.17)pointed atNavStateQPVPBias, 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 whatcalibration.py's own docstrings assign at ten sites.tests/core/sensors/test_zupt_statistic_and_process_model.pybacks the two entries that had no real conformance test.Also folded in:
data/sim/ch6_foot_zupt_walk/README.md's executed block called bareplt.savefig(...)and wrote a stray SVG to the repo root on every docs-test run — two more inch6_strapdown_basic. All three areplt.show()now, matching every other dataset README.Suspected new issues (not fixed here)
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_variancecannot 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.svgwas committed with its trailing whitespace stripped — every other SVG inch6_dead_reckoning/figs/keeps it, so that file was not whatsave_figurewrites. 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 passedtests/core/sensors— 329 passed (tests/coreoverall 1482 passed, 6 skipped)tests/docs— 880 passed, 1 skipped (includestest_readme_example_output.py -k ch67 passed andtest_readme_code_blocks.py -k ch610 passed, each also run alone)tests/test_datasets_reproduce_from_their_recipe.py— 43 passedtests/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 passedtests/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 skippedplot_error_cdf's newxscaledefaults to"linear": ch3 and ch5 figures regenerate byte-identically🤖 Generated with Claude Code