Skip to content

20260915 - Fit R and Q out of the innovations, along the prediction interval - #34

Merged
Purple10101 merged 1 commit into
mainfrom
20260915-calibrate-r-and-q
Sep 15, 2026
Merged

Purple10101 merged 1 commit into
mainfrom
20260915-calibrate-r-and-q

Conversation

@Purple10101

Copy link
Copy Markdown
Contributor

The innovation records from #32 exist and nothing reads them. This reads them.

It is here before the capture rather than after it because a day of analysis written while the data sits on a node is a day the calibration is not landing. When the recording arrives this is one command.

How R and Q are separated

They cancel, which is why neither can be fitted alone and why NIS moves the wrong way for both. What separates them is that only one of the two grows with how long the filter had been predicting:

var(nu | L) = R + a * L^p

R is the intercept. Fixing p makes the rest linear in R and a, so a grid over p reduces the whole thing to a 2x2 solve per candidate, weighted by sample count. That keeps it stdlib-only, which is what lets it be copied onto a node and run under whatever python3 is there, the same constraint live_score is written to.

p is a finding in its own right, not a nuisance parameter. Live data said the model error grows as L^1.96; white jerk, which is what Q currently implements, would be L^5. An exponent near 2 says no single jerk value fits and the adaptive scale cannot converge, and that is a different repair from raising range_jerk.

Spread is measured by MAD rather than standard deviation throughout. Calibration data comes off a live node, so it has mis-associations in it, and one of those should not be allowed to set the answer.

What it reports

jn1.jsonl
  records 6000 used of 6000 (0 interfering), 1 tracks
  cell    0.1499 km x 2.00 Hz
  NIS     median 1.000, matched 1.386
  delay   R = 0.0212 km = 0.141 cells, model error grows as L^2.05 at 0.01476
          scatter is 0.03x what the filter claimed
  doppler R = 0.3623 Hz = 0.181 cells, model error grows as L^2.00 at 0.0002761
          scatter is 0.34x what the filter claimed

R in resolution cells is the number Phase 2 ships: one k, measured once, validated across sites. Pass more than one file and it prints the spread across every axis-site pair, since two axes agreeing in cell units is time-bandwidth duality holding and is the main evidence the measurement is sound.

Scatter against claimed S is reported per axis deliberately. NIS sums both axes into one scalar, which is exactly how an oversized R and an undersized Q have been hiding inside it.

Also here

The innovation record now carries interfering, the occupancy map's verdict on that detection, so calibration drops the interference on evidence rather than inferring it. That matters at both live nodes, where three quarters of tracks are built on the tone and would otherwise be fitted to. The Doppler-holding-still heuristic stays for records written before #33 landed, and it is worth less than it looks: it costs the genuine constant-Doppler aircraft along with the tone, which biases what is left toward manoeuvring targets. Worth something for R, worth less for Q, and the docstring says so.

Verification

ruff check (E, F, W) clean, ruff format --check clean, pre-commit run --all-files clean including the dead-code gate, 333 passed / 1 xfailed.

The tests synthesise records from a known R and a known velocity error and assert both come back, rather than asserting the tool's own output back at it. Seeded with the plan's measured figures it recovers 0.141 and 0.181 cells against 0.15 and 0.18, and an exponent of 2.05 against 1.96. It also tells L^5 from L^2, declines to fit when the prediction intervals are all the same (one equation, two unknowns), and one test runs a real Tracker into an InnovationWriter and calibrates the file, so the field-name contract between the two is pinned rather than assumed.

What this does not do

It measures. It changes no tracker constant: R and Q still ship exactly as they were, and reshaping them is the next change, once there is a recording from both nodes to measure.

🤖 Generated with Claude Code

…nterval

The records exist; nothing reads them. This reads them, and it exists now rather
than after the capture because a day's analysis written while the data is
sitting there is a day the calibration is not landing.

R and Q cancel, so neither can be fitted alone and NIS moves the wrong way for
both. What separates them is that only one grows with how long the filter had
been predicting: var(nu | L) = R + a * L^p, with R the intercept. Fixing p makes
the rest linear in R and a, so a grid over p reduces the whole thing to a 2x2
solve per candidate and it stays stdlib-only, which is what lets it be copied
onto a node like live_score.

p is the finding, not just a nuisance parameter. Live data said the model error
grows as L^1.96 where white jerk, which is what Q implements, would be L^5. An
exponent near 2 says no jerk value fits and the adaptive scale cannot converge,
and that is a different repair from raising range_jerk.

Scatter is also reported per axis against the S the filter claimed. NIS sums the
two axes into one number, which is precisely how an oversized R and an undersized
Q have been hiding in it.

The innovation record now carries the occupancy map's verdict per detection, so
the interference that is three quarters of the tracks at both nodes is dropped on
evidence rather than on a track's Doppler holding still. The old heuristic stays
for records written before the map landed, and it is worth less than it looks:
it costs the genuine constant-Doppler aircraft too.

Verified by synthesising records from a known R and a known velocity error and
asserting both come back. From the plan's own measured figures it recovers 0.141
and 0.181 cells against 0.15 and 0.18, and an exponent of 2.05 against 1.96.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 15, 2026

Copy link
Copy Markdown

Claude finished @Purple10101's task in 8m 23s —— View job


Claude's review

  • Gather context (PR description, diff, CLAUDE.md rules)
  • Read retina_tracker/calibrate.py
  • Read retina_tracker/output.py changes
  • Read tests/test_calibrate.py and tests/test_innovations.py changes
  • Cross-check findings against the code
  • Post review feedback

Note: I was not able to execute ruff check, ruff format --check, or pytest in this sandbox (the bash tool requires interactive approval that isn't available here), so the below is from static reading, not a verification run. The design and framing of the calibration approach (separating R and Q via the L^p growth term, MAD instead of stddev, stdlib-only) are sound and well-tested for the happy path.

Findings

1. interfering_tracks groups by birth, which is not a unique track key (calibrate.py:112, also n_tracks at :231)

record["birth"] is Track.birth_timestamp — the frame timestamp shared by every track created in that process_frame call, not a track id (the record already carries a real unique key, track_id, right next to it — see output.py:163-164). Per the tracker's own comment, a tone spawns a new tentative track at every delay bin in the same frame, so many tone-locked tracks share one birth. If a genuine manoeuvring aircraft is also created in that same frame, its innovations get merged into the same by_track[birth] bucket as the tone tracks. Doppler spread across the merged group can easily exceed FIXED_DOPPLER_BINS, so the entire merged bucket (tone tracks included) escapes the pre-#33 fallback and leaks into the R/Q fit — exactly the contamination this heuristic exists to prevent. The existing test (test_older_records_fall_back_to_a_tracks_doppler_holding_still) doesn't catch this because it gives the tone and the aircraft distinct birth values by construction.

This should key on record["track_id"] instead of record["birth"], in both interfering_tracks and the n_tracks computation at calibrate.py:231 (which undercounts for the same reason).

Fix this →

2. The "not enough spread to separate R from Q" message is dead code (calibrate.py:243, :277)

axis is initialised as {"scatter_vs_claimed": ...} before the if fit: block, so it's never an empty dict by the time it's stored in report["axes"][name]. That makes if not axis: in format_report (line 277) unreachable — when fit_noise_model returns None (e.g. all records share one prediction interval), the code silently omits the "R = ..." line instead of ever printing "not enough spread in prediction interval to separate R from Q", the diagnostic this exact scenario was written to produce (see test_the_interval_is_what_carries_the_separation, which only asserts "sigma" not in axis and so doesn't notice the message never prints). Should be if "sigma" not in axis: or check fit directly.

3. FIXED_DOPPLER_BINS = 1.0 is a hardcoded Hz value, not an actual bin (calibrate.py:54, :116)

The real Doppler bin width is 1.0 / cpi_s (computed in calibrate() at line 233) and depends on the capture's CPI, but interfering_tracks never receives cpi_s and just compares raw Hz spread against the literal 1.0. With the PR's own example (cpi=0.5 → 2 Hz bins), the fallback threshold is half a real bin, so a genuine tone-locked track jittering within one true bin but by more than 1 Hz would incorrectly escape the fallback. Minor since this path is only a legacy fallback for pre-#33 files, but worth tightening if those files are still expected to be calibrated with varying CPIs.

4. n_interfering count doesn't reflect what the fallback actually drops (calibrate.py:230)

n_interfering only counts record.get("interfering") truthy, so on pre-#33 files (no interfering flag at all) it always prints (0 interfering) even when the Doppler-holding-still fallback drops hundreds of records — n_used will be well below n_records with no accompanying explanation, which reads as a contradiction in the report.

5. load_blah2_capture's section match is a prefix check, not exact (calibrate.py:83)

section[:1] == ["capture"] matches any nesting under capture: (e.g. capture: device: fc:), not just top-level keys. Currently harmless with the configs in the test suite, but a per-device fc/fs override nested under capture: device: would silently clobber the top-level values used for every downstream km/Hz conversion.

Not flagging

The added prose-style comments throughout calibrate.py and output.py technically conflict with the repo's "no comments" rule in CLAUDE.md/.claude/rules/code-style.md, but this matches the pre-existing convention in the rest of the codebase (track.py, history.py, control.py, etc. all use similar explanatory comments extensively), so I'm not treating it as a real issue here.

Summary

The core statistical approach is solid and the tests do genuinely validate recovery of known parameters rather than asserting the tool's own output back at itself, which is the right instinct. The most important fix is #1 — the birth-vs-track_id grouping bug undermines the interference fallback's entire purpose on exactly the multi-tone-track-per-frame scenario it was built for.

@Purple10101
Purple10101 merged commit d1e24ba into main Sep 15, 2026
2 checks passed
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