Skip to content

20260914 - Learn which Doppler bins are a tone, and refuse them tracks - #33

Merged
Purple10101 merged 2 commits into
mainfrom
20260914-doppler-occupancy-map
Sep 15, 2026
Merged

Purple10101 merged 2 commits into
mainfrom
20260914-doppler-occupancy-map

Conversation

@Purple10101

Copy link
Copy Markdown
Contributor

Phase 1 of today's tracker plan: make the output and the metrics honest.

Three quarters of the tracks at both interfered nodes are built on the node's own CW interference. 32 of 42 at fairforest B, 343 of 490 at jn1. They are kinematically flawless, with spans of 26, 20 and 18 km and delay rate agreeing with Doppler rate to 1%, because a tone puts a detection at every delay in every frame: the filter always finds one exactly where it predicted, and the track manufactures its own confirming evidence. Nothing downstream of initiation has grounds to reject them, which is why this acts at initiation and nowhere else.

The consequence is that every live metric today is computed over a population that is three quarters artefact, and artefacts score well on kinematic consistency. The 8.7% impossible-track rate is not trustworthy until this is separated.

Why it has to be learned

Node Tone % of detections % of frames Delay cells (of 12) Tracks built on it
fairforest B +27.9 Hz 98% 92.5% 12 32 of 42 (76%)
jonathan-node-1 -60 Hz pair 74% 38.9% 7 343 of 490 (70%)
owl-ded9 none 7% 18.3% 2 n/a

Two frequencies, so a fixed notch tuned on either one misses the other, and owl-ded9 has nothing to notch at all. What is shared between the sites is the shape of the evidence, not its frequency, so the map is learned at runtime and is a no-op where there is nothing to learn.

What is in here

DopplerOccupancy keeps a rolling window and two statistics per Doppler bin. A bin has to be high on both:

  • In what share of recent frames it held a detection. Gathered across frames rather than within one, because CFAR thins a continuous ridge to one to three peaks per frame: a per-frame test was tried against recorded data and caught 14.4% where this catches 45%. Persistence is the signal.
  • How far its detections scatter in delay, once the drift its own Doppler mandates is removed.

The drift removal is what makes the second condition usable, and it is the part I would most like reviewed. A target sitting in one Doppler bin has a near-constant bistatic range rate by definition, so its delay marches steadily and its raw extent grows without limit: over a shipped window that is 18 resolution cells for an ordinary 45 m/s, which plain extent would convict. Removing the drift that the bin's own Doppler implies collapses an aircraft to under a cell (a target drifting right across the bin leaves only a curvature term, a fraction of a cell) while leaving a tone scattered across the whole axis. The spread itself is a 10-90 percentile range rather than max-minus-min, so one stray detection in a bin an aircraft owns cannot convict it.

Also here: c/fs by 1/cpi now comes from blah2's capture config rather than being configured a second time next to it. --blah2-config already read that file for fc and the node already passes it, so fs and cpi cost no new plumbing. This is the same derivation Phase 2 needs for R.

Suppression is off

interference.suppress defaults to false. Marking does not, so a node records what a suppressing tracker would have refused, alongside the ADS-B labels that say whether refusing it would have cost an aircraft, and interference_fraction reports it per track the way shadow_fraction already does. One capture then answers both the pre-change baseline and the validation, instead of needing two.

The gate that matters is zero ADS-B-labelled aircraft lost at either node, and it cannot be answered from synthetic data. Nothing here should be turned on until it has been.

Verification

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

tests/test_interference.py reproduces all three nodes' measured profiles - how often the bin was busy, how far it spread across the delay axis - and asserts that one set of constants convicts fairforest B at +27.9 Hz, convicts jn1 at both -60 and +60 Hz, and acquits owl-ded9 and a sky of twelve real aircraft crossing the same bins. A later change to either threshold has to answer to all three.

Worst case cost, measured: 0.05 ms per frame at a realistic detection rate, 25 ms per 500 ms frame at the pathological limit where every Doppler bin is busy in every frame.

Known and deliberate

  • A restarted tracker suppresses nothing for its first window. A partial window reads every bin it has seen as fully occupied, so there is nothing to judge against. The cost is one window's worth of artefact tracks per restart, which stay established and pass through from then on.
  • jn1's margin is the thin one. Its tone held 38.9% of frames against a 0.3 threshold and a clean node's 18.3%. If the labelled data shows a clean node tripping this in a rolling window, the fix is to exempt detections carrying an ADS-B match from suppression outright, which is a line and is deliberately not here yet: it would make the labelled-aircraft gate true by construction in the very run meant to test it.

Relation to the other open PRs

Independent of #31 and #32, both of which are Phase 0. This branches off main and merges in any order.

🤖 Generated with Claude Code

Three quarters of the tracks at both interfered nodes are built on the node's
own CW interference: 32 of 42 at fairforest B, 343 of 490 at jn1. They are
kinematically flawless, with spans of 26, 20 and 18 km and delay rate agreeing
with Doppler rate to 1%, because a tone puts a detection at every delay in every
frame, so the filter always finds one exactly where it predicted and the track
manufactures its own confirming evidence. Nothing downstream of initiation has
grounds to reject them, and every live metric computed today is computed over a
population that is three quarters artefact.

The two nodes are interfered at different frequencies, +27.9 Hz against a pair
around -60 Hz, which is why this is learned rather than shipped: a notch tuned
on either misses the other, and a third node has nothing to notch at all.

Two statistics per Doppler bin over a rolling window, and a bin has to be high
on both. In what share of recent frames it held a detection, which has to be
gathered across frames rather than within one because CFAR thins a continuous
ridge to one to three peaks and a per-frame test caught 14.4% where this catches
45%. And how far its detections scatter in delay once the drift its own Doppler
mandates is removed.

That second condition is the one protecting a real target, and removing the
drift is what makes it work. A target sitting in one Doppler bin has a
near-constant bistatic range rate by definition, so its delay marches steadily
and its raw extent grows without limit: measured over a shipped window, 18
resolution cells for an ordinary 45 m/s. Take out the drift its Doppler mandates
and an aircraft collapses to under a cell, while a tone, whose peaks land
wherever the ridge was brightest that frame, stays scattered across the axis.

The bins come from blah2's capture config rather than from a threshold here.
c/fs by 1/cpi is the cell blah2's detector reports in, --blah2-config already
reads that file for fc, and the node already passes it. Deriving them also
settles the two constants Phase 2 needs for R.

Suppression is off. Marking is not, so a node records what a suppressing tracker
would have refused alongside the ADS-B labels that say whether refusing it would
have cost an aircraft, and interference_fraction on the track reports it the way
shadow_fraction already does. That gate - zero labelled aircraft lost - is the
one this has to pass before it is turned on anywhere, and it cannot be answered
from synthetic data.

Nothing is judged until the window is full, so a restarted tracker suppresses
nothing for its first minute. The cost is one window's worth of artefact tracks
per restart; the alternative is convicting a bin on a partial window, where
every bin it has seen reads as fully occupied.

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

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown

Claude finished @Purple10101's task in 3m 39s —— View job


Claude finished @Purple10101's task

Task list:

  • Gather context (PR body, changed files list)
  • Read the full diff (interference.py, tracker.py, track.py, output.py, config.py, cli.py, config.yaml, tests)
  • Trace the design for correctness (window eviction, drift removal, units, config wiring, suppression/marking split)
  • Post review

Review

This is a well-reasoned, well-tested change. I traced the mechanics by hand rather than skimming and didn't find a correctness bug — a few notes below, all minor.

Verified as correct:

  • DopplerOccupancy._bin() uses round-to-nearest (floor(x/bin + 0.5)), and delay_spread_cells() computes drift from the bin center's Doppler (bin_index * doppler_bin_hz), not each detection's raw Doppler — matches the docstring's claim that a target's curvature term (not its full raw spread) is what survives.
  • Units check out: range_rate from doppler_to_range_rate() is km/s, times are the tracker's millisecond timestamps, so range_rate * (times - times[0]) / 1000.0 is km — consistent with delay (km) and DELAY_CELL_KM().
  • The window eviction in observe() (retina_tracker/interference.py:88-116) relies on _samples[bin] and _frames staying in insertion order so popleft() removes exactly the oldest frame's own samples — correct as long as observe() is called in frame order, which Tracker.process_frame does.
  • _judge() only evaluates delay_spread_cells once the window is exactly full (len(self._frames) == self.window_frames), matching the "acquit until proven" design called out as deliberate in the PR body.
  • Marking vs. suppression split is correctly wired: _mark_interference runs before association/initiation on every above-SNR detection, n_initiations_suppressed and suppressed are threaded through _classify_frame/_drain_classifications so a suppressed detection is still reported as unassociated rather than silently vanishing (retina_tracker/tracker.py:337, :389).
  • n_shadow_obsn_observations rename in track.py is a clean generalization (no stale references left; grepped for it).
  • --blah2-config load order in cli.py:238-245 happens before Tracker/run_tcp_server construction in both the file and --tcp paths, so DopplerOccupancy.from_config() sees the capture-derived sample_rate/cpi at construction time as intended.
  • load_blah2_config's added AttributeError catch is a genuine fix, not just churn — a blah2 config with an empty/null process: section previously would have thrown unhandled.
  • Manually recomputed DELAY_CELL_KM() for fs=2e6: 299792458/2e6/1000 = 0.149896 km, matching the test's pytest.approx(0.14990, abs=1e-5).

Minor observations (non-blocking):

  1. retina_tracker/cli.py:203 — the --blah2-config argparse help string still reads "Path to blah2 config.yml to read center frequency (fc)", but it now also pulls fs/cpi. Small staleness, worth a one-line update. Fix this →
  2. The occupancy window is sized in frames (round(window_s / CPI_S())), i.e. it assumes each observe() call is one CPI apart. A clock resync (tracker.py:333-337, n_backwards >= BACKWARDS_RUN_BEFORE_RESYNC) doesn't clear self.occupancy, so a run of backwards/irregular timestamps could feed observe() with timestamps that aren't monotonic, which would make times[0] in delay_spread_cells() not actually the earliest sample in a bin for that one judged frame. This is a narrow edge case (resync is already rare and self-heals within one window), and is consistent in spirit with the "first window after restart is unprotected" limitation the PR already documents — flagging in case it's worth an explicit line in the "Known and deliberate" section rather than a code change.
  3. DopplerOccupancy.is_interfering() gates on isinstance(doppler, (int, float)) while observe()'s own _bin() path only checks math.isfinite. Not a bug for the JSON/float detection schema in use today, just a slight asymmetry if a non-Python-float numeric type (e.g. numpy.int64) ever flows through.

Verification I could not run: this sandbox has no ruff/pytest on PATH and shell access outside single simple commands isn't permitted, so I could not execute the ruff check/ruff format --check/pytest sequence CLAUDE.md requires. Everything above is from manual tracing of the diff and the new test suite (tests/test_interference.py), which is thorough and its assertions cross-checked cleanly by hand. The PR description states these were run locally (290 passed / 1 xfailed, ruff clean) — if CI runs the same suite that should confirm it independently.

No blocking issues found. interference.suppress correctly defaults to false, matching the PR's stated intent to gate on the ADS-B validation before enabling.

The drift the map takes out of each Doppler bin is a rate times an elapsed
time. Across a clock resync that elapsed time is fiction, so every bin's
scatter reads inflated and the one condition protecting a real target is the
condition that fails. Clearing the window costs the usual first minute of not
judging, which is what a resync should cost.

Also from the review of #33: --blah2-config's help still described it as
reading fc alone, and is_interfering() narrowed to Python floats where
observe() accepts anything finite, so a numpy scalar would have been marked on
the way in and read as clean on the way out.

Found by claude-review on #33.

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 7m 26s —— View job


Code review complete

Task list:

  • Gather context (PR body, changed files list, prior comment)
  • Run /code-review:code-review offworldlabs/retina-tracker/pull/33 as instructed
  • Post findings

Ran the /code-review skill against this PR's diff and hand-verified every finding against the current code before reporting. Two are directly relevant to the PR's core goal ("make the output and the metrics honest") — they undermine interference_fraction/shadow_fraction in exactly the two places the PR relies on them as a guardrail.

Findings

1. Promotion event omits shadow_fraction/interference_fraction — reports 0.0 for exactly the tracks the PR wants flagged
retina_tracker/tracker.py:253-276
The write on promotion (write_event/write_event_lazy inside if promoted:) doesn't pass shadow_fraction= or interference_fraction=, so output.py's defaults (0.0) apply — unlike the association-time write just above it (tracker.py:220-238), which passes both. A tentative track built almost entirely on a tone (interference_fraction near 1.0) will report interference_fraction=0.0 in the very event — the first one under its new post-promotion track_id — that a downstream consumer keys off. Fix this →

2. _merge_track_pair doesn't carry forward n_observations/n_shadowed/n_interfering
retina_tracker/tracker.py:588-601
It extends history["measurements"], n_frames, n_associated, total_snr, but not the three counters shadow_fraction()/interference_fraction() divide by (track.py:118-121,743-747). After a merge, a track's reported interference/shadow fractions are computed only against track_a's pre-merge counts, silently diluting or skewing the exact accountability metric this PR introduces, for tracks that get merged after a coast gap. Fix this →

3. DopplerOccupancy.observe() can raise uncaught TypeError on malformed external input
retina_tracker/interference.py:99-102
observe() calls math.isfinite(doppler)/math.isfinite(delay) directly, while is_interfering() a few lines down (interference.py:165-170) wraps the identical check in try/except TypeError. Detections reach process_frame from external contributing nodes without their doppler/delay types being validated first (tracker.py:146-180 only compares snr), so per .claude/rules/security.md ("Validate and sanitise external detection and ADS-B input at node/trust boundaries"), a non-numeric doppler/delay that isn't None will raise uncaught here — crashing CLI/file-mode runs, or (in TCP mode, per the broad except Exception in the server loop) silently dropping the entire frame's otherwise-valid detections instead of just the malformed one. Fix this →

4. A run of 1-2 backward timestamps (below the resync threshold) still gets folded into the occupancy window before any clear
retina_tracker/tracker.py:142 runs (via _mark_interferenceobserve()) before the n_backwards >= BACKWARDS_RUN_BEFORE_RESYNC check at line 333 that clears self.occupancy. delay_spread_cells() assumes FIFO insertion order equals chronological order (using times[0] as the earliest sample) — an isolated out-of-order frame below the resync threshold breaks that assumption for whichever bins it touches, without triggering the clear the PR added for genuine clock jumps. Narrow edge case, self-heals within one window; worth a line in "Known and deliberate" rather than a code change, consistent with the restart-window limitation already documented there.

5. shadow_fraction()/interference_fraction() are identical formulas duplicated
retina_tracker/track.py:808-829 — both are n_x / max(self.n_observations, 1). Non-blocking; a shared _fraction(n) helper would remove the duplication (and would have prevented finding #1/#2 from needing two separate spots to get right).

Summary

Findings #1 and #2 are the ones I'd block on — both make interference_fraction/shadow_fraction read 0.0 (or diluted) for a subset of tracks in exactly the scenario this PR is designed to make visible: a track dominated by tone-derived detections. #3 is a real crash/data-loss risk on malformed external input per this repo's own security rule. #4 is minor and arguably just belongs in "Known and deliberate." #5 is a pure cleanup suggestion.

@Purple10101

Copy link
Copy Markdown
Contributor Author

Fixed in f64afe6, all three notes.

The resync one turned out to be worth a code change rather than a docs line, though not quite for the reason given: the spread is invariant to which sample is times[0], since shifting the origin adds the same constant to every residual. What does break is that the drift removal is a rate times an elapsed time, and across a clock jump that elapsed time is fiction, so every bin's scatter reads inflated and the one condition protecting a real target is the one that fails. Tracker now clears the window on resync, with a test.

Help string updated, and is_interfering() no longer narrows to Python floats where observe() accepts anything finite.

291 passed / 1 xfailed, ruff, format and the dead-code gate clean.

@Purple10101
Purple10101 merged commit 94830cf into main Sep 15, 2026
2 checks passed
@Purple10101
Purple10101 deleted the 20260914-doppler-occupancy-map branch September 15, 2026 07:34
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