20260914 - Record what the filter predicted, so R and Q can be separated - #32
Conversation
R and Q are both wrong, in opposite directions, and they cancel. Measured against recorded detections this morning: R is about 45x too large in delay and 6x in Doppler, while the model error it is standing in for grows as the square of the prediction interval rather than the fifth power that white jerk implies, so no jerk value fits and the adaptive scale cannot converge. The sum comes out plausible, which is why nothing has complained. Neither can be fitted from the events file. That records the detections which were associated, never what the filter predicted before it saw them, and the difference between those is the only quantity either constant answers to. NIS alone will not do either: a single scalar moves one way for an oversized R and the other for an undersized Q, which is exactly how they have been hiding. Reconstructing innovations offline by replaying recorded detections works, and is how the figures above were obtained, but it cannot see the filter's real state: a replay guesses at the covariance, the adaptive scale and the coasting history that shaped each prediction. This records them instead. Each record carries its own measurement. Most tracks at an interfered site are built on a fixed-Doppler tone, 70% on one node and 76% on the other, and have to be excluded before anything is fitted to them. A track that never confirmed has no id and never reaches the events file, so the Doppler has to travel with the record rather than be joined back to it. Off unless --innovations names a path, and bounded by the same rotation as the events file so a node left recording cannot fill its disk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @Purple10101's task in 2m 23s —— View job Claude finished reviewing PR #32Task list
SummaryThe core plumbing ( Bug:
|
Every recorded n_missed was 0. Track.update() zeroes it on the way through, and write_residual reads it from the track after update() has returned, so the field said "this prediction followed an association" for every record including the ones that followed ten coasted frames. That is the field the record exists for. Prediction interval and coasting history are what separate Q from R, and the reconstruction this replaces could at least count the gaps between associations; recording it and getting it wrong is worse than not recording it, because nothing about the file says the column is dead. Latched next to last_q_scale, which avoids the same trap for the same reason: both describe the prediction the innovation was measured against, and both are overwritten before the method ends. Found by claude-review on #32. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @Purple10101's task in 1m 15s —— View job Review of PR #32Tasks
Previously flagged bug is fixedMy earlier pass on this PR flagged that
Nice, minimal fix — no unrelated churn. Rest of the PR (re-checked against current HEAD)
I did not find any further issues. Note on verification: I could not execute |
|
Fixed in 3121ef0. 273 passed / 1 xfailed, ruff and format clean. |
RandQare both wrong, in opposite directions, and they cancel. Measured this morning against recorded detections on jn1 (n=402):Ris about 45x too large in delay and 6.3x in Doppler, while the model error it is standing in for grows asL^1.96rather than theL^5that white jerk implies. No single jerk value fits, so the adaptive scale cannot converge. The sum comes out plausible, which is why nothing has complained.Neither constant can be fitted from the events file. That records the detections which were associated, never what the filter predicted before it saw them, and the difference between those is the only quantity either one answers to. NIS alone will not do either: a single scalar moves one way for an oversized
Rand the other for an undersizedQ, which is exactly how they have been hiding from it.Reconstructing innovations offline by replaying recorded detections works, and is how the figures above were obtained, but it cannot see the filter's real state. A replay has to guess at the covariance, the adaptive scale and the coasting history that shaped each prediction. This records them instead.
What is in here
Residual, aNamedTupleof(innovation, S, nis).kalman.update()computed all three and returned only the NIS, discarding the two that separateRfromQ.Residual.degenerate()keeps the singular-Spath honest rather than letting it pass NaNs off as a measurement.InnovationWriter, subclassingTrackEventWriterfor its rotation, writing one JSONL record per update:track_id,birth,timestamp,dt,snr,delay,doppler,n_missed,q_scale,innovation,s_diag,nis.--innovations PATHon the CLI, threaded through both the TCP server and the file path. Absent, nothing is built and nothing is written.tests/test_innovations.py, new: record shape, the degenerate path, rotation, and that the writer stays off by default.Each record carries its own measurement deliberately. Most tracks at an interfered site are built on a fixed-Doppler tone, 70% on jn1 and 76% on fairforest B, and have to be excluded before anything is fitted to them. A track that never confirmed has no id and never reaches the events file, so the Doppler has to travel with the record rather than be joined back to it.
Cost when off
innovation_writerdefaults toNoneand the call site is guarded, so an unflagged node builds no record rather than building one and dropping it. This is the same shape asdetection_sink.Cost when on
Bounded by
OUTPUT_MAX_BYTES/OUTPUT_BACKUP_COUNT, the same rotation as the events file, so a node left recording cannot fill its disk.Why now
Phase 0 of today's tracker plan. This afternoon's data window is a simultaneous daylight capture on both nodes, and it is expensive to repeat. Landing this beforehand is what makes Phase 2's calibration direct and repeatable rather than an offline reconstruction of state the tracker already knew.
Verification
ruff check(E, F, W) clean,ruff format --checkclean, 271 passed / 1 xfailed.Merges cleanly with #31, the other Phase 0 branch.
🤖 Generated with Claude Code