Skip to content

Chapter 4 prices the clock state against a beacon that answers back - #132

Merged
qmohsu merged 2 commits into
mainfrom
claude/ch4-rtt-matched-comparison
Sep 2, 2026
Merged

qmohsu merged 2 commits into
mainfrom
claude/ch4-rtt-matched-comparison

Conversation

@qmohsu

@qmohsu qmohsu commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Chapter 4's inline comparison had no two-way row, so "what does estimating the receiver clock actually cost?" was answerable only by intuition. example_toa_positioning does run an RTT chain, but on different anchors, a different target and a different error budget — its 0.704 m and this table's numbers were never comparable, and a deck built from the two would have compared them anyway.

The design: one varied factor

The new RTT (two-way TOA) series shares the anchors, the 50 test points, the seed, the sigma ladder and the realisation drawn from it — draw_range_noise is called once per level and handed to both range series. Only the state vector differs:

measurement state clock bias
TOA one-way pseudorange (x, y, c·δt), Eqs. (4.24)-(4.26) 1.5 m injected
RTT Eq. (4.7) converted range (x, y), TOAPositioner none — the round trip cancels it

Sharing the draw rather than redrawing is the point: aoa_bearings exists in this same file because the weighted and unweighted AOA columns used to draw their own noise, so their ratio carried a full realisation on each side. Same defect, avoided before it was written.

RTT is deliberately not noised through processing time and clock drift, though simulate_rtt_measurement offers exactly that. Doing so would vary the error budget as well as the state vector, and the row could attribute the difference to neither. The standalone RTT section keeps teaching that cost and is untouched. Nothing here charges RTT for the transponder either, so the result is the best case for two-way ranging, not a net verdict on it.

Measured

Median over all 50 fixes, python -m ch4_rf_point_positioning.example_comparison:

Level σ TOA/RTT (m) AOA (deg) RSS (dB) TOA RTT TDOA AOA AOA unw RSS RTT fail AOA fail RSS fail
1 0.00 0.0 0.0 0.000 0.000 0.000 0.000 0.000 1.652 0 0 21
2 0.05 1.0 2.0 0.044 0.044 0.047 0.121 0.355 2.219 0 0 19
3 0.10 3.0 4.0 0.088 0.083 0.091 0.384 1.430 3.246 0 0 26
4 0.20 5.0 6.0 0.162 0.152 0.175 0.519 2.433 4.029 0 0 34
5 0.50 10.0 8.0 0.507 0.431 0.567 1.129 7.026 8.045 1 4 38

TOA/RTT ratio: — / 1.00× / 1.06× / 1.07× / 1.18×.

Read that ratio as one number, not a trend

Adding the clock column to H inflates the position covariance by a factor set by the geometry alone — it does not grow with σ. Analytically this array gives 1.026 (median of per-point DOP ratios; 1.033 as a ratio of medians). The 1.00→1.18 spread is a 50-point median resolving a 2.6% effect through roughly ±7% of sampling noise: over 40 repeats per level the ratio of medians ranges 0.87 to 1.27, so a single level cannot even establish the sign.

So the tests pin the claim where it is real, and pin the shipped ordering separately as a property of seed 42:

  • exactly, at every one of the 50 points, via the Schur complement of the clock column;
  • statistically, over a 1200-point sample where 2.6% is resolvable — mean ratio bounded in (1.010, 1.045), a floor measured at ~4 SE below the observed mean of 1.0257 (10 seeds, std 0.0070);
  • as a regression pin, RTT ≤ TOA at every noisy level of the shipped sweep, docstringed as a realisation pin rather than as physics.

The four pre-existing series are byte-identical

Verified at full precision, not at three decimals: running main's copy of the module and this one in a single process, every error value, solved mask and n_failed of TOA, TDOA, AOA, AOA_unw and RSS agree with max|difference| = 0.0. The mechanism is that draw_range_noise returns None at zero σ instead of a zero array, so the noiseless level still consumes nothing from the global stream, and the new series consumes nothing at any level.

Two defects found in this change's own output

  • The ratio line first guarded on median > 0. The noiseless medians are 3.5e-10 m and 9.9e-10 m — both zero to a positioning system, both nonzero to float division — so it printed "0.35×" as the cost of the clock state. The floor is now STALL_M, the solver's own 1e-6 m step tolerance.
  • A first test asserted the recovered bias was 1.5 m on noisy data; it reads 1.4642, because the solve absorbs the injected bias plus whatever common part the noise happens to have. What is exact there is the difference between the two clock estimates, and that is what is asserted now.

A log y-axis was rendered and rejected, not argued about

RSS at 8 m does crush the range series near zero, and the sibling geometry figure fixes exactly that with set_yscale("log"). It does not work here: level 1 is noiseless, so those medians are roundoff and a log axis spends ten of twelve decades on them, compressing every informative level into the top fifth of the panel. Recorded in the panel comment and the README so the next reader does not repeat it. Colours and dashes are now keyed by method rather than positional, so inserting a series cannot silently recolour the four already there; RTT is purple with its own dash pattern, needed because four of five methods coincide at 100 % in the solved-fixes panel.

Verified

  • tests/ch4_rf_point_positioning — 78 passed (11 new).
  • Docs guards — 751 passed / 1 skipped: test_chapter_architecture_sections, test_readme_file_structure, test_chapter_readme_code_blocks, test_readme_example_output (the transcript pin), test_documented_flags_exist, test_documented_paths_exist, test_documented_commands_use_module_form.
  • test_repo_conventions + test_lint_debt_only_shrinks + test_examples_answer_help — 522 passed.
  • test_every_figure_has_a_demo_behind_it — 203 passed, 8 skipped.
  • ruff check on both changed files reports the same 3 pre-existing typing.Dict findings as main, and nothing new.
  • python -m tools.chapter_dependencies — "already up to date" (no new core dependency).
  • python -m ch4_rf_point_positioning.example_comparison exits 0; the regenerated PNG was opened and read — legend legible, five series present, nothing clipped.
  • The notebook is untouched and needs no change: it imports only compare_geometries, which this PR does not modify.

Three mutations were run to check the new guards can fail: giving RTT the clock state too makes the ratio exactly 1.0000 (the position estimates become bit-identical, since a common bias is absorbed whole into c·δt); letting RTT redraw its own noise breaks the shared-draw test; injecting the bias into RTT takes its median to ~1.2 m and its failures to 47–48 of 50.


🤖 Generated with Claude Code

qmohsu and others added 2 commits September 2, 2026 13:17
…back

The inline comparison had no two-way row, so "what does estimating the receiver
clock actually cost?" was answerable only by intuition. `example_toa_positioning`
does run an RTT chain, but on different anchors, a different target and a
different error budget -- its 0.704 m and this table's numbers were never
comparable, and a deck built from the two would have compared them anyway.

**The design is one varied factor, and the discipline is the experiment.** The
new `RTT (two-way TOA)` series shares the anchors, the 50 test points, the
seed, the sigma ladder AND the realisation drawn from it -- `draw_range_noise`
is called once per level and handed to both range series. Only the state
vector differs: TOA solves `(x, y, c*dt)` per Eqs. (4.24)-(4.26) because a
one-way pseudorange carries an unknown receiver clock, RTT solves `(x, y)`
because Eq. (4.7)'s round trip is timed on one clock and cancels it. No bias is
injected into RTT.

Sharing the draw rather than redrawing is the point: `aoa_bearings` exists
because the weighted and unweighted AOA columns used to draw their own noise,
so their ratio carried a full realisation on each side. Same defect, avoided
before it was written.

**RTT is deliberately NOT noised through processing time and clock drift**,
though `simulate_rtt_measurement` offers exactly that. It would vary the error
budget as well as the state vector and the row could attribute the difference
to neither. The standalone section keeps teaching that cost and is untouched.

Measured (median over all 50 fixes, sigma 0 -> 0.5 m):

    sigma  0.00   0.05   0.10   0.20   0.50
    TOA    0.000  0.044  0.088  0.162  0.507
    RTT    0.000  0.044  0.083  0.152  0.431
    ratio  --     1.00x  1.06x  1.07x  1.18x

**That column is one number, not a trend, and reading it as a trend was the
trap.** The clock column inflates the position covariance by a factor set by
the geometry alone -- it does not grow with sigma. Analytically this array
gives 1.026 (median of per-point DOP ratios). The 1.00-1.18 spread is a
50-point median resolving 2.6% through about +/-7% of sampling noise: over 40
repeats per level the ratio of medians ranges 0.87 to 1.27, so a single level
cannot even establish the sign. The tests therefore pin the claim where it is
real -- exactly, at every one of the 50 points, via the Schur complement, and
over a 1200-point sample where 2.6% is resolvable -- and pin the per-level
ordering separately, labelled as a property of the shipped seed.

**The four pre-existing series are byte-identical, verified at full precision
rather than at three decimals.** Running main's copy of the module and this one
in a single process, every error value, `solved` mask and `n_failed` of TOA,
TDOA, AOA, AOA_unw and RSS agree with max|difference| = 0.0. The mechanism is
that `draw_range_noise` returns `None` at zero sigma instead of a zero array,
so the noiseless level still consumes nothing from the global stream, and the
new series consumes nothing at any level.

**Two defects were found and fixed in this change's own output.** The ratio
line first guarded on `median > 0`, and the noiseless medians are 3.5e-10 m and
9.9e-10 m -- both zero to a positioning system, both nonzero to float division
-- so it printed "0.35x" as the cost of the clock state. The floor is now
`STALL_M`, the solver's own 1e-6 m step tolerance. And a first test asserted the
recovered bias was 1.5 m on noisy data; it reads 1.4642, because the solve
absorbs the injected bias plus whatever common part the noise has. What is
exact there is the *difference* between the two clock estimates, and that is
what is asserted now.

**A log y-axis was rendered and rejected rather than argued about.** RSS at 8 m
does crush the range series near zero and the sibling geometry figure fixes
exactly that with `set_yscale("log")` -- but level 1 is noiseless, so those
medians are roundoff and a log axis spends ten of twelve decades on them,
compressing every informative level into the top fifth of the panel. The reason
is recorded in the panel's comment and in the README so the next reader does
not repeat the twenty minutes.

Colours and dash patterns are keyed by method rather than positional, so
inserting a series cannot silently recolour the four that were already there;
RTT is purple with its own dash pattern, needed because four of five methods
coincide at 100% in the solved-fixes panel.

Verified: 78 passed in `tests/ch4_rf_point_positioning` (11 of them new); 751
passed / 1 skipped across the docs guards, including the README transcript pin;
`test_repo_conventions` and the lint ratchet green; ruff on the two changed
files reports the same 3 pre-existing `typing.Dict` findings as main and
nothing new; `python -m ch4_rf_point_positioning.example_comparison` exits 0
and the regenerated figure was opened and read.

Three mutations were run to check the new guards can fail: giving RTT the clock
state too makes the ratio exactly 1.0000 (the position estimates become
bit-identical, since a common bias is absorbed whole into `c*dt`); letting RTT
redraw its own noise breaks the shared-draw test; injecting the bias into RTT
takes its median to ~1.2 m and its failures to 47-48 of 50.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`tests/test_black_is_actually_run.py` caught it on CI and nothing caught it
here: black is in the `dev` extra and was simply not installed on this
machine, so the guard could not run at all. CLAUDE.md's own line for this --
"a configured checker that is not run is indistinguishable from one that
passes" -- with the twist that the checker was absent rather than unconfigured.
black 26.5.1 installed to match the pin in `pyproject.toml`, which exists
because versions disagree: black 26 reformats files black 25 accepts.

Whitespace only: four wrapped expressions rejoined onto single lines. No
assertion, tolerance or number moved, and `tests/ch4_rf_point_positioning`
still reads 78 passed. `example_comparison.py` needed no change.
@qmohsu
qmohsu merged commit 60f57d1 into main Sep 2, 2026
1 check passed
@qmohsu
qmohsu deleted the claude/ch4-rtt-matched-comparison branch September 2, 2026 07:25
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