Skip to content

Two-way TOA earns its place in Chapter 4 - #129

Merged
qmohsu merged 1 commit into
mainfrom
claude/ch4-two-way-toa-earns-its-place
Aug 31, 2026
Merged

qmohsu merged 1 commit into
mainfrom
claude/ch4-two-way-toa-earns-its-place

Conversation

@qmohsu

@qmohsu qmohsu commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

The defect

ch4_rf_point_positioning/example_toa_positioning.py argued the opposite of what the chapter teaches, by juxtaposition:

  • Examples 1-2 solve one-way TOA at 0.1 m of range noise and print 0.000 / 0.055 m. The synchronised-clock assumption appeared nowhere in the file — grep -i sync returned nothing.
  • Example 5 (RTT, Eqs. 4.6-4.9) uses an honest ns-level timing budget and prints 0.718 m RMSE, 0.704 m of position error.

A reader flipping through sees two-way as 7x worse — a comparison between one protocol's assumptions and another's physics. The RTT math itself was correct and is unchanged.

The three-case comparison (Example 7)

One 20 m array, agent at (8, 12), one number — sigma_t, the agent clock's per-timestamp jitter — and 2000 draws per case, every draw scored:

case median RMSE failed predicted
A one-way, clocks synchronised 0.2518 0.3018 0 0.3000
B(i) one-way, 20 ns offset, position only 1.7978 1.8416 1 1.7239
B(ii) one-way, 20 ns offset, clock solved (Eqs. 4.24-4.26) 0.2545 0.3039 0 0.3014
C two-way RTT, no sync needed 0.2148 0.2613 0 0.2598

Position DOP 1.0007 (position only) against 1.0053 with the clock as a third unknown — the +0.45% B(ii) pays.

The bookkeeping, derived rather than chosen per case

  • One-way: one receive timestamp against a scheduled transmit epoch, so sigma_range = c*sigma_t = 0.2998 m.
  • Two-way: two timestamps on the agent's own clock plus the turnaround calibration residual, sigma_RTT = sqrt(2*st^2 + sp^2), halved by Eq. (4.7) — 0.2596 m.

This is the bookkeeping that flatters two-way least: giving the transmit epoch its own sigma_t would make one-way c*sqrt(2)*sigma_t and double the gap. Verified against the simulation rather than asserted — measured range std matches two_way_range_sigma to within 0.5% for sigma_proc from 0 to 3 ns, so both knobs demonstrably reach the measurement.

Reported disagreement with the expected ordering

A <= C was expected; C comes out 13% ahead of A. That is arithmetic, not a thumb on the scale — a round trip puts twice the distance into the interval being timed while the jitter grows only as sqrt(2). The example prints the crossover (sigma_proc = sqrt(2)*sigma_t = 1.414 ns, where the two range equally well) rather than moving a parameter to recover the expected order.

Checks

  • Zero-noise sanity: 0.000000 m for all four arms at sigma_t = 0, b = 0, sigma_proc = 0.
  • B(i) bias: linearised prediction b*||(H'H)^-1 sum(u_i)|| = 1.1323 m, measured noiseless 1.6976 m. The example shows why: at b = 0.01 m the ratio is 1.001, so the gap is nonlinearity — 6 m of bias on 11-17 m ranges is not a small perturbation. The agent is off-centre deliberately; at a symmetric array's centre sum(u_i) is zero and a common bias is invisible.
  • Mutation-checked transcript: perturbing one new digit in the README block turns test_readme_example_output.py red.
  • Output reproducible across processes; committed figure byte-identical; no dataset moved.

Also, in passing

Example 5's single-draw positioning number is labelled as one draw with the scale to expect beside it, and its Monte-Carlo RMSE gets the prediction it should match (0.8072 m predicted against 0.7180 m measured over 100 draws).

Documentation

  • Chapter README: a clock-requirement table over the four approaches; the RTT section gains the sqrt(2) derivation and an explicit "this does not beat TDOA"; transcript re-pasted from a real run.
  • data/sim/ch4_rf_2d_square/README.md: names what toa_ranges.txt assumes, and adds two-way TOA to the equation list.

Test plan

  • tests/ch4_rf_point_positioning tests/core/rf — 243 passed
  • tests/docs/test_readme_example_output.py -k ch4 alone — 3 passed
  • tests/docs/test_readme_code_blocks.py + test_chapter_readme_code_blocks.py — 57 passed, 1 skipped
  • repo conventions + console encoding + import-checkout + python-floor + figure gate + black + lint — 806 passed, 8 skipped
  • test_repo_validators.py test_semantic_api_debt_only_shrinks.py + lint/mypy/black/doctest trio — 34 passed
  • docs guards (flags, paths, module form, file structure, architecture, equation ranges, reader guidance, semantic naming) — 739 passed, 1 skipped
  • test_datasets_reproduce_from_their_recipe.py test_examples_answer_help.py — 82 passed

🤖 Generated with Claude Code

`example_toa_positioning` argued the opposite of what the chapter teaches, by
juxtaposition. Examples 1-2 solve one-way ranges at 0.1 m of measurement noise
and printed 0.000 / 0.055 m; Example 5 measures RTT on an honest nanosecond
timing budget and printed 0.718 m RMSE, 0.704 m of position error. A reader
flipping through saw two-way as 7x worse -- a comparison between one
protocol's assumptions and another's physics, not between protocols. Grepped
before believing it: the word "sync" appeared nowhere in the file.

Three changes.

**The assumption is stated where it is made.** Examples 1, 2 and the module
docstring now say that one-way TOA as solved here needs the clocks already
synchronised, and that 1 ns of unmodelled offset is 0.300 m at c. Example 2's
range-noise line carries its own size in nanoseconds (0.33), which is the
comparison that matters: the whole error budget of Examples 1-2 is smaller
than the offset a free-running consumer oscillator reaches in a fraction of a
second.

**Example 7 measures it.** One 20 m array, one agent position, one number --
sigma_t, the agent clock's per-timestamp jitter -- and four solves over 2000
draws each, every draw scored:

    case                                     median     RMSE  failed  predicted
    A     one-way, clocks synchronised       0.2518   0.3018       0     0.3000
    B(i)  one-way, offset, position only     1.7978   1.8416       1     1.7239
    B(ii) one-way, offset, clock solved      0.2545   0.3039       0     0.3014
    C     two-way RTT, no sync needed        0.2148   0.2613       0     0.2598

A, B(ii) and C land on DOP x sigma_range. Twenty nanoseconds of unmodelled
offset costs 1.8 m; estimating it instead (Eqs. 4.24-4.26) recovers almost all
of it at 0.45% of position DOP; two-way needs neither.

**The bookkeeping is derived, not chosen per case.** One-way is a single
receive timestamp against a scheduled epoch, sigma_range = c*sigma_t. Two-way
is two timestamps on the agent's own clock plus the turnaround calibration
residual, sigma_RTT = sqrt(2*st^2 + sp^2), halved by Eq. (4.7). It is the
bookkeeping that flatters two-way least -- giving the transmit epoch its own
sigma_t would double the gap. Verified against the simulation rather than
asserted: measured range std matches `two_way_range_sigma` to within 0.5% over
sigma_proc from 0 to 3 ns, so both knobs demonstrably reach the measurement.

**Reported disagreement.** C comes out 13% ahead of A, where A <= C was
expected. That is arithmetic, not tuning: a round trip puts twice the distance
into the interval being timed while the jitter grows only as sqrt(2). The
example prints the crossover -- the two protocols range equally well at
sigma_proc = sqrt(2)*sigma_t = 1.414 ns -- rather than moving a parameter to
recover the expected order.

Checks: zero-noise sanity is 0.000000 m for all four arms. B(i)'s bias is
predicted by b*||(H'H)^-1 sum(u_i)|| = 1.1323 m against 1.6976 m measured, and
the example shows why -- at b = 0.01 m the ratio is 1.001, so the gap is
nonlinearity and 6 m of bias on 11-17 m ranges is not a small perturbation.
The agent is off-centre deliberately: at the centre of a symmetric array
sum(u_i) is zero and a common bias is invisible.

Also, in passing: Example 5's single-draw positioning number is labelled as one
draw with the scale to expect beside it, and its Monte-Carlo RMSE gets the
prediction it should match (0.8072 m against 0.7180 m over 100 draws).

READMEs: the chapter's TOA section gains a clock-requirement table over the
four approaches, the RTT section gains the derivation and an explicit "this
does not beat TDOA", and the square dataset README names what `toa_ranges.txt`
assumes. The transcript block is re-pasted from a real run and was
mutation-checked -- perturbing one new digit turns it red.

Output verified reproducible across processes; the committed figure is
byte-identical; no dataset moved.
@qmohsu
qmohsu merged commit 56f27b9 into main Aug 31, 2026
1 check passed
@qmohsu
qmohsu deleted the claude/ch4-two-way-toa-earns-its-place branch August 31, 2026 08:08
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