Skip to content

fix: a figure that says NED is not a rotation, and quat_to_euler at the lock - #130

Merged
qmohsu merged 1 commit into
mainfrom
claude/ch2-figures-and-lock-branch
Aug 31, 2026
Merged

qmohsu merged 1 commit into
mainfrom
claude/ch2-figures-and-lock-branch

Conversation

@qmohsu

@qmohsu qmohsu commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Chapter 2's mathematics is clean. A fresh sweep of 400 random attitudes (seed
42, roll off the lock) agrees across quaternion, matrix and Euler:

invariant worst
quat_to_rotation_matrix(euler_to_quat) vs euler_to_rotation_matrix 6.7e-16
Euler round trip through the quaternion 1.6e-14
Euler round trip through the matrix 4.0e-15
matrix -> quat -> matrix 7.4e-16
reconstruction from quat_to_euler 1.5e-14
both lock branches, exact +/-90, 10 named cases 2.2e-16

Everything below is a claim printed beside that mathematics, plus one bug
that lives on a single point.

A committed figure stated a falsehood

ch2_frame_chain's suptitle read "NED is a handedness-preserving
swap-and-flip of ENU, not a rotation". Measured from the shipped matrix:
det = +1.0, turn = 180.0 deg, axis = [0.7071, 0.7071, 0] -- the NE
bisector. It is a proper rotation, and that is why handedness survives: a
swap-and-flip that were not one would have det = -1 and would hand a
right-handed triad to a left-handed frame. The caption now derives all three
numbers from C_enu_to_ned at draw time, so it cannot disagree with the
matrix again.

quat_to_euler was wrong at exactly roll = +/-90

Not degraded -- wrong. euler_to_quat(pi/2, -30 deg, 0) came back as
[90, 180, 180] deg, whose matrix differs from the input by 0.5: a
different attitude, not a gimbal-equivalent triple. At 89.999 deg it is exact
to 6e-12.

The mechanism is signed zeros, which is why the docstring's "degrades near the
lock" was the wrong description. At the singularity both arctan2 calls
receive (0.0, -2.2e-16) -- a zero numerator beside a negative denominator
-- so each returns +pi instead of 0, twice. It is a branch that exists only
on the boundary, not a precision loss that grows.

rotation_matrix_to_euler has always carried an explicit lock branch. The
quaternion path now mirrors it entry for entry -- same 1 - 1e-12 threshold,
same copysign, same fix-yaw-solve-pitch convention -- reading sin_roll,
C[1,0] and C[1,1] out of Eq. (2.21) so the two share one convention
rather than two that happen to agree.

The red, before the fix: 19 failed / 14 passed (git stash on
core/coords/rotations.py, new test only). After: 33 passed.

Which assertion did the work is worth knowing, because it was not the obvious
one:

  • "roll comes back as +/-90 with its sign" passes on the broken code. Roll
    was never the wrong part.
  • The reconstruction test passes on the broken code at (90, 0, 0) and
    (-90, 0, 0), because [90, 180, 180] has the same pitch - yaw. Only
    several distinct (yaw, pitch) pairs discriminate -- which is why the
    parametrisation carries five per branch.

Both tolerances are measured against the noise and the defect: 1.6e-7 of
reconstruction against a 2*sqrt(2*LOCK_EPS) = 2.8e-6 gate derived from the
threshold itself, and 6.4e-11 of path disagreement against a 1e-9 gate, versus
0.906 and 3.14 rad for the defect.

PR #66's renumbering never reached the dataset

config.json, the generator that writes it, and ten cites in the dataset
README still advertised "2.1 (LLH->ECEF), 2.2 (ECEF->LLH), 2.3 (ECEF->ENU)".
Per docs/equation_index.yml those are the local body/map vector
definitions
and map <-> body; the geodetic chain is 2.9 and 2.10.

Regenerating from the recorded san_francisco preset moved the four
equations strings in config.json and nothing else -- every coordinate,
rotation and accuracy byte identical, including rotation_roundtrip_deg.
tests/test_datasets_reproduce_from_their_recipe.py passes after.

The wrong equation on ch2_passive_vs_active

The panel said "Passive: C, Eq. (2.21)". (2.21) is quaternion-to-matrix; the
relation drawn is (2.11) and the matrix is built by
euler_to_rotation_matrix = (2.17). Fixed in the panel, the module
docstring, the References line and ch2_coords/README.md.

Smaller, each measured

  • The yaw panel's x' axis had collapsed to a stub. VIEW_AZIM was 35.0 --
    and so was the yaw panel's own rotation angle, so the one axis that panel
    exists to show pointed straight at the camera. Over every triad drawn under
    the shared view (three figures, nine panels, plus the dashed reference in
    each) the shortest on-screen axis length was 0.342 of full at (20, 35)
    and is 0.578 at (35, 45). The maximiser is (36, 45) at 0.587.
  • The gimbal figure's top row was labelled by roll alone, hiding that every
    panel also carries yaw = 30 -- the 30 the bottom row's argument is about.
  • ecef_to_llh's tol was documented "(meters)" and is compared against a
    latitude difference in radians. Measured: at 20 200 km, tol=1e-3 leaves
    18 mm of height error at 38 deg latitude and 218 m at 89 deg; at the
    surface it costs nothing at any latitude.
  • example_coordinate_transforms printed Location: Unknown -- the key is
    config['reference_point']['location'] -- and printed
    Difference: 5.77e-04 m (should be ~0) beside "sub-nanometer accuracy".
    Those two argued. The 0.58 mm is the file's %.3f storage quantum, bounded
    by sqrt(3) * 0.5 mm = 0.87 mm; the worst of the 20 shipped points is
    0.835 mm. The print site says so now.
  • docs/CH2_QUICK_REFERENCE.md claimed 55 passing tests (68), 16 functions
    (17, since enu_to_llh_offset), and cited CHAPTER_2_MAPPING_SUMMARY.md,
    which exists nowhere (exhaustive unbounded grep: one hit, the citation
    itself). The counts are replaced by their sources; a count in prose rots
    silently because there is no failing run to notice. The hand-written "Last
    Updated" line went the same way.
  • _C_ENU_NED read source-first while its own comment, three sibling
    docstrings and docs/equation_index.yml all use C^to_from. Renamed
    _C_NED_ENU -- one outlier moved to match six neighbours, rather than
    rewriting the convention.
  • euler_to_quat does not canonicalise the hemisphere and
    rotation_matrix_to_quat does, so a round trip sign-flips on 10 of the 20
    shipped attitudes. Behaviour deliberately unchanged -- quaternions.txt
    records these signs -- but both docstrings now say so, with the double-cover
    sentence and how to compare two quaternions without tripping over it.
  • The dataset File Structure omitted rotation_matrices.txt and README.md.

Figures

All four regenerated and every PNG opened. All 12 files re-render
byte-identically on a second run, so the committed diff is the picture change
and nothing else.

Test plan

  • tests/core/coords tests/ch2_coords -- 118 passed
  • tests/docs/test_readme_example_output.py -k ch2 -- 2 passed
  • tests/docs/test_readme_code_blocks.py -k "ch2 or ch6" -- 12 passed,
    repo root clean afterwards
  • tests/test_datasets_reproduce_from_their_recipe.py,
    test_repo_validators.py, test_semantic_api_debt_only_shrinks.py --
    47 passed
  • lint / mypy / black / doctest quartet -- 30 passed
  • test_repo_conventions.py, test_every_figure_has_a_demo_behind_it.py,
    test_examples_import_this_checkout.py,
    test_python_floor_matches_syntax.py -- 723 passed, 8 skipped (10m24s)
  • docs path / flag / module-form / file-structure / equation-range guards --
    685 passed
  • tests/docs/test_notebooks_run.py -k ch2 -- 2 passed
  • tests/test_examples_answer_help.py -- 39 passed
  • tests/test_example_console_encoding.py -- 79 passed

Not a finding

A cross-agent report placed a bare
plt.savefig('zupt_trajectory_stance.svg') in an executed ```python block
at data/sim/ch6_foot_zupt_walk/README.md:204, writing SVGs to the repo root
on every docs-test run. It is already fixed on main and was not
reproducible at 56f27b9: an unbounded grep for savefig|save_figure in that
file returns nothing, line 204 is # Velocity over time, and commit 885b9f6
(an ancestor of main) removed exactly that line. Independently, cf0ccb5
made tests/docs/test_readme_code_blocks.py neutralise plt.savefig,
plt.show and plt.Figure.savefig for the duration, so the block could not
write even if it existed. Verified anyway: -k "ch2 or ch6" green, root clean.

Suspected, not fixed

ch2_coords/README.md's "Expected Output" pins inline mode only
(<!-- example-output: ch2_coords.example_coordinate_transforms -->, no
--data). All three of this example's defects -- Location: Unknown and the
two arguing accuracy claims -- lived in the dataset branch, which nothing
pins. Worth a second marked transcript, but that is a new guard rather than a
fix and is left for a follow-up.

🤖 Generated with Claude Code

…he lock

Chapter 2's mathematics is clean -- 400 random attitudes agree across
quaternion, matrix and Euler to 1.6e-14. Everything here is a claim printed
beside that mathematics, plus one bug that lives on a single point.

**`ch2_frame_chain`'s suptitle stated a falsehood.** It read "NED is a
handedness-preserving swap-and-flip of ENU, not a rotation." Measured from the
shipped matrix: det = +1.0, turn = 180.0 deg, axis = [0.7071, 0.7071, 0] --
the NE bisector. It is a proper rotation, and that is *why* handedness
survives; a swap-and-flip that were not one would have det = -1. The caption
now derives all three numbers from `C_enu_to_ned` at draw time, so it cannot
disagree with the matrix again.

**`quat_to_euler` was wrong at exactly roll = +/-90, and only there.** Not
degraded -- wrong. `euler_to_quat(pi/2, -30 deg, 0)` came back as
[90, 180, 180] deg, whose matrix differs from the input by **0.5**: a
different attitude, not a gimbal-equivalent triple. At 89.999 deg it is exact
to 6e-12. The mechanism is signed zeros: at the singularity both `arctan2`
calls receive (0.0, -2.2e-16), a zero numerator beside a negative denominator,
so each returns +pi instead of 0. `rotation_matrix_to_euler` has always
carried a lock branch; the quaternion path now mirrors it entry for entry --
same threshold, same `copysign`, same fix-yaw-solve-pitch convention -- so the
two share one convention rather than two that happen to agree.
`tests/core/coords/test_quat_to_euler_at_the_lock.py` pins it: 19 of its 33
assertions fail against the old code, and its two tolerances are measured
against both the noise (1.6e-7, 6.4e-11) and the defect (0.906, 3.14 rad).

Note which assertion did the work. "roll comes back as +/-90 with its sign"
passes on the *broken* code. So does the reconstruction test at (90, 0, 0),
because [90, 180, 180] has the same pitch-minus-yaw. Only several distinct
(yaw, pitch) pairs discriminate.

**PR #66's equation renumbering never reached the dataset.** `config.json`,
the generator that writes it, and ten cites in the dataset README still
advertised "2.1 (LLH->ECEF), 2.2 (ECEF->LLH), 2.3 (ECEF->ENU)". Per
`docs/equation_index.yml` those are the local body/map *vector definitions*
and map <-> body; the geodetic chain is 2.9 and 2.10. Regenerating from the
recorded `san_francisco` preset moved the four `equations` strings in
`config.json` and **nothing else** -- every coordinate, rotation and accuracy
byte identical.

Also: `ch2_passive_vs_active` labelled the passive relation Eq. (2.21), which
is quaternion-to-matrix; the relation is (2.11) and the matrix drawn is
(2.17). Fixed in the panel, the module docstring, the References line and the
chapter README.

Smaller, each measured:

- **The yaw panel's x' axis had collapsed to a stub.** `VIEW_AZIM` was 35.0
  and so was the yaw panel's rotation angle, so the one axis that panel exists
  to show pointed straight at the camera. Over every triad drawn under the
  shared view the shortest on-screen axis was 0.342 of full; at (35, 45) it is
  0.578.
- The gimbal figure's top row was labelled by roll alone, hiding that every
  panel also carries yaw = 30 -- the 30 the bottom row's argument is about.
- `ecef_to_llh`'s `tol` was documented "(meters)" and is compared against a
  latitude difference in **radians**. At 20 200 km, tol=1e-3 leaves 18 mm of
  height error at 38 deg latitude and 218 m at 89.
- `example_coordinate_transforms` printed `Location: Unknown` -- the key is
  `config['reference_point']['location']` -- and printed
  "Difference: 5.77e-04 m (should be ~0)" beside "sub-nanometer accuracy".
  Those two argued; the 0.58 mm is the file's `%.3f` storage quantum, bounded
  by sqrt(3) * 0.5 mm = 0.87 mm, and the worst of the 20 shipped points is
  0.835 mm. The print site says so now.
- `docs/CH2_QUICK_REFERENCE.md` claimed 55 passing tests (68), 16 functions
  (17), and cited `CHAPTER_2_MAPPING_SUMMARY.md`, which exists nowhere. The
  counts are replaced by their sources, since a count in prose rots silently.
- `_C_ENU_NED` read source-first while its own comment, three sibling
  docstrings and `docs/equation_index.yml` all use `C^to_from`. Renamed to
  `_C_NED_ENU` -- one outlier moved to match six neighbours.
- `euler_to_quat` does not canonicalise the hemisphere and
  `rotation_matrix_to_quat` does, so a round trip sign-flips on 10 of the 20
  shipped attitudes. Behaviour unchanged -- `quaternions.txt` records these
  signs -- but both docstrings now say so.
- The dataset File Structure omitted `rotation_matrices.txt` and `README.md`.
@qmohsu
qmohsu merged commit 8a30d0b into main Aug 31, 2026
1 check passed
@qmohsu
qmohsu deleted the claude/ch2-figures-and-lock-branch branch August 31, 2026 08:56
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