Skip to content

Measure ADS-B freshness at capture, not at handling - #275

Merged
Babissimo merged 4 commits into
mainfrom
fix/adsb-capture-timestamps
Aug 28, 2026
Merged

Babissimo merged 4 commits into
mainfrom
fix/adsb-capture-timestamps

Conversation

@Babissimo

@Babissimo Babissimo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes ClickUp 86cb9br6k. Branch fix/adsb-capture-timestamps.

What this fixes

Three places stamped an ADS-B position's freshness with the time it was handled rather than the time it was captured, and one fabricated the timestamp outright for external-cache entries that carried none. Every 60 s staleness gate keys off that stamp, so the defences switched off exactly when the data was stalest.

The sharpest case was calibration: record_adsb_calibration refuses a fix older than CAL_MAX_ADSB_AGE_S (10 s), but with fresh_adsb reporting now the age was always zero, so a fix up to a poll interval old (120 s, or 420 s behind a rate-limit backoff) entered the learned FOV as if it were current. That is 30 km at 250 m/s, feeding the polygon that judges solves.

Commits

7cc9e22 Capture timestamps on both ingest paths and the external cache, plus expiry at both cache layers
42c806a adsb.lol ground speed stored in m/s, not knots — its own commit, as the ticket asks
b226719 Fixes from review of the first two
b3c1b85 Merge main (see below)

main is merged in rather than rebased, so the three commits above stay reviewable in isolation. It was not housekeeping: the branch was 17 commits behind and still carried the pre-fix deploy/tower-contract.sh, so its tower-service-contract check failed on an open-meteo rate limit that b459167 and 8e84750 had already fixed on main. CI runs that script from the PR's own branch, so a bare re-run could not have passed.

The units defect is isolated in its own commit: it is a separate bug that merely shares a location, and it was ~1.94x high on every external truth entry, since OpenSky 429s anonymous access and adsb.lol is the only path that runs in production.

Notes for review

b226719 is the interesting one. A review of the first two commits found that making freshness honest broke two things that had been quietly relying on every ADS-B stamp coming off the server clock:

  • Calibration stopped entirely for any node more than 2 s out. CAL_FIX_DETECTION_SKEW_S bounds a fix against the node's last detection, and that detection stamp is server wall clock — so comparing it against a node-clock capture stamp turned a co-timing rule into an NTP test. Records now carry recv_ms (server clock) alongside last_seen_ms (capture); the skew rule uses one, the age gates the other.
  • An adsb.lol outage made stale data look fresher than before. seen_pos is an age relative to the fetch that produced the row, and the client serves last-good rows again on a later failed fetch, so resolving it against the current poll re-stamped a frozen snapshot as seconds old every cycle. AdsbLolClient now publishes an absolute captured_at, resolved at fetch time.
  • last_seen_ms could go backwards, because adsb_aircraft is keyed by hex alone and written from two paths for the same frame. A queued replay walked the stamp back by the queue latency and feed_gc evicted aircraft being reported at 50 Hz. Writes are guarded now.

Cross-validation's sample watermark is deleted rather than patched — it caused four separate defects (a future-dated timestamp_ms bought a node permanent immunity; an omitted one skipped every sample; samples sharing a frame's millisecond collapsed to one judgement; deferred samples were never re-judged). The guarantee it reached for already holds: the admission window is far shorter than the interval between cycles, and there is now a test pinning that coupling.

Deliberately not done

  • The 60–300 s skew band. A node 90 s backlogged and a node 90 s slow are indistinguishable from a single frame; the former is reported honestly and correctly dropped by the 60 s gates, the latter is fresh data we discard. Narrowing the bound would re-stamp genuine deep backlog as fresh, which is the original defect. Distinguishing them needs arrival time tracked separately from capture time.
  • /api/v1/ground-truth/real's source field still says opensky_network whatever fed it. It is a published field and the per-entry provenance it wants belongs to 86cb9btw9. It does gain an additive per-entry captured_at, since its docstring invites callers to measure solver accuracy against those positions.
  • Cross-validation only fires on samples captured near a poll. Gating both sides at 15 s is what a 10 km bar can honestly support, but the cache refreshes every 120 s. Widening its reach means dead-reckoning the entry to the sample's time, not a bigger budget — raised as 86cbaxf1n and linked.

Sequencing

Site 1 of 86cb5p1jy holds behind this, per the decision recorded on the ticket: it rewrites the same external_adsb_cache write sites, so this one owns the entry schema outright. It will add a source field to the same dict, which should merge cleanly alongside last_seen_ms and recv_ms.

Verification

Full backend suite green (exit 0, zero failures, coverage 83.15% against a 55% gate) and pre-commit run --all-files clean. 51 new tests across four files, including a regression file with one test per review finding. Re-run after merging main: still exit 0.

Not yet verified on a live map. Green tests do not cover the compose/env/frontend seams, so this wants a look at testmap.retina.fm once it is deployed.

Worth a baseline either side, because two numbers people watch will move by design: /api/test/solver-stats and /api/test/mlat-accuracy. External ADS-B fixes stop feeding calibration, which is the point of the change, and adsb.lol-sourced velocities drop by 1.94x.

🤖 Generated with Claude Code

Babissimo and others added 3 commits August 27, 2026 14:57
Every staleness gate in the pipeline keys off an entry's last_seen_ms, and
three places set it to the moment the backend handled a position rather than
the moment it was measured. The gates then read stale data as current, which
is worst exactly when the data is worst.

The external cache was the sharpest case: fresh_adsb fabricated
int(now * 1000) for entries carrying no timestamp at all, so calibration's
CAL_MAX_ADSB_AGE_S (10 s) and its fix-vs-detection skew rule both saw an age
of zero. A fix up to a poll interval old (120 s, or 420 s behind a rate-limit
backoff) entered the learned FOV as if it were current, which is 30 km at
250 m/s, and the coverage polygon is what judges solves.

Both feeds publish the age of each position, so the capture time is available
rather than inferred: OpenSky's time_position, adsb.lol's seen_pos. The poll
wall-time is a fallback now, not the source. Node frames carry
frame["timestamp"], which both ingest paths already had in hand for
association and simply did not use for the ADS-B store.

Honest stamps alone were not enough, because nothing ever removed an entry:
both fetch paths only replace the cache wholesale, so if every source failed
the last good snapshot persisted for the length of the outage behind a log
line. Two expiries close that, one per layer. external_adsb_cache is pruned
every poll cycle, outside the fetch because the fetch returns early when no
real node is connected and those are exactly the runs that would otherwise
leave a stale cache untouched. AdsbLolClient's per-area last-good cache ages
out too: bounding only the outer one would leave stale positions arriving
from underneath it.

Node clocks are trusted within ADSB_CAPTURE_MAX_SKEW_S and no further. Past
that the clock is broken rather than the frame late, and a stamp minutes into
the future is never stale to any gate at all. Those frames fall back to
receipt time and are counted, surfaced as adsb_capture_ts_fallback, so the
condition is visible rather than silent.

Cross-validation of node ADS-B reports against external truth is gated on the
same timestamps. It has been dead, the cache being near-empty for an
unrelated reason, and it is not safe to wake as written: the penalty is 0.1
against a block threshold of 0.2, it is persisted across restarts, and
apply_reward is a no-op once blocked. Comparing positions measured minutes
apart, re-charging the same sample every cycle, or reading the report route's
default (0, 0) as a position claim would each block a truthful node. Gating
both sides on freshness, judging each sample once, and ignoring null island
leave the 10 km bar measuring disagreement rather than elapsed time.

That confines the check to samples captured near a poll. Widening its reach
means dead-reckoning the entry to the sample's time, not a larger budget;
raised separately rather than folded in here.

ClickUp 86cb9br6k.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two writers of external_adsb_cache disagreed on units for velocity.
OpenSky's field is metres per second and was stored as it arrived, correctly.
adsb.lol serves tar1090, whose gs is knots, and it was stored raw. Both
consumers read the field as m/s: analytics_refresh assigns it straight to
gs_ms under a comment explicitly asserting this is not the tar1090 schema,
and track_gates multiplies by 1.94384 to recover knots for the display feed.

So every adsb.lol-sourced ground speed was about 1.94x high wherever it was
consumed: verification's velocity-error statistics, and the solver's
ADS-B-anchored initial guess and its dead-reckoning. OpenSky returns 429 for
anonymous access, so adsb.lol is the only path that runs in production and
this was every external truth entry rather than a fallback minority.

It has not been observed in the wild only because the cache has been
near-empty for an unrelated reason (86cb5p1jy site 1: the query bbox collapses
to a single point). It would surface the moment that lands, which is why the
schema is corrected here first rather than in the branch that fills the cache.

The same function has always converted alt_baro from feet, so tar1090's units
were understood when this was written; only the speed was missed.

ClickUp 86cb9br6k.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the two commits below it. Making last_seen_ms a capture time was
right, but three of its consequences were not, and two of them disabled the
very defences the change exists to restore.

The client, not the poller, owns capture time. tar1090's seen_pos is an age
relative to the fetch that produced the row, and AdsbLolClient serves last-good
rows again on a later failed fetch, so resolving it against the current poll
re-stamped a frozen snapshot as seconds old on every cycle. Nine minutes into
an adsb.lol outage a 542 s position read as 2 s: the prune dropped nothing,
fresh_adsb served it as current, calibration's 10 s gate accepted it, and the
cross-validator compared honest nodes against nine-minute-old truth and charged
them 0.1 of persisted reputation a cycle. AdsbLolClient now resolves seen_pos
at fetch time and publishes an absolute captured_at, which stays true however
long the row is served.

Records carry recv_ms alongside. record_adsb_calibration bounds a fix against
the node's last detection at 2 s, and that detection stamp is server wall
clock; comparing it against a node-clock capture stamp turned a co-timing rule
into an NTP test, and a board 3 s out recorded no calibration points at all.
The age gate keeps the capture stamp, the skew rule gets the server one.

Writes are guarded against regression. adsb_aircraft is keyed by hex alone and
written from two paths for the same frame, so once the stamp came from the
frame rather than the clock, a queued replay walked it backwards by the queue
latency and feed_gc evicted aircraft that were being reported at 50 Hz.

The node-supplied stamp is bounded asymmetrically now, in both directions it
matters: the past gets the width of a real backlog because that is honest, the
future only jitter, because a stamp ahead of us is not stale to any gate and
never becomes one. The same rule reaches the two writers that were missed, the
sim push (forwards only, so replay still works) and the feeds themselves.

Cross-validation loses its sample watermark entirely. It was meant to stop one
bad fix being re-charged until a node blocked, but it keyed on an unvalidated
node-supplied timestamp: one future-dated report pinned it and bought that node
permanent immunity, an omitted timestamp_ms defaulted to 0 and skipped every
sample forever, samples sharing a frame's millisecond collapsed to one
judgement, and a sample deferred by any other gate was never judged again. The
guarantee it was reaching for already exists — the admission window is far
shorter than the interval between cycles — so the watermark is gone and the
coupling is stated instead. The route stamps timestamp_ms when a node omits it.

Cross-validation and the prune also move into _adsb_truth_cycle's finally: a
fault inside cross-validation was being caught as an OpenSky failure, which
churned the connection pool, ran cross-validation twice in one cycle, and left
the prune skipped — freezing the cache the prune exists to age.

Remaining: consumers scoring solves against external truth now gate on
EXTERNAL_TRUTH_MAX_AGE_S rather than trusting the poller's retention bound,
which runs on the fetch cadence and is ten minutes wide; an empty adsb.lol
result clears the cache instead of leaving the previous one to chain the two
budgets into a 19-minute stale window; feed values go through is_num, so a bare
NaN drops its own row rather than raising and blanking the whole poll; and
fresh_adsb uses valid_latlon, which it needs on the prime meridian.

/api/v1/ground-truth/real gains a per-entry captured_at, since it invites
callers to measure solver accuracy against positions it was presenting as
current. Its "source" field still says opensky_network whatever fed it; that is
a published field and the per-entry provenance it wants belongs to 86cb9btw9.

ClickUp 86cb9br6k.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

This comment has been minimized.

@claude

claude Bot commented Aug 27, 2026

Copy link
Copy Markdown

Claude finished @Babissimo's task in 1m 23s —— View job


Claude's review of #275 (re-check after sync)

Tasks

  • Gather context (PR description, changed files, prior review)
  • Diff origin/main...HEAD to see what changed since the last review
  • Determine whether new review work is needed

Summary

This trigger fired from a synchronize event, but the only new commit is b3c1b85, a merge of origin/main (bringing in #273, an unrelated CI worktree-guard change) into this branch — it introduces no changes of its own. The PR's actual diff is byte-for-byte the same as what was already reviewed: git diff origin/main...HEAD still shows 24 files changed, +1360/-58, identical file list and line counts to the prior review.

Nothing new to review here, so the prior review stands as-is. For reference, its one open finding is still unaddressed:

backend/services/calibration.py:65record_adsb_calibration's docstring still says fix_ts is last_seen_ms / 1000 (the node's capture clock), but since b226719 the only real caller (track_gates.py:743) passes recv_ms (the server receipt clock) instead, specifically to keep fix_ts on the same clock as detection_ts. calibration.py itself wasn't touched by this PR, so the docstring now describes the pre-fix behavior. Minor, but worth a one-line fix so a future reader of that module in isolation isn't misled.

Fix this →

No other issues found; nothing further to check since the code itself hasn't changed.
· branch fix/adsb-capture-timestamps

@Babissimo
Babissimo merged commit e52bb88 into main Aug 28, 2026
29 checks passed
Babissimo added a commit that referenced this pull request Aug 28, 2026
86cb9br6k landed as #275 while this branch was open, and the two changed the
same cache from opposite ends: it made every entry carry the time its position
was captured and fixed adsb.lol's ground speed from knots to m/s, while this
branch replaced the fleet-wide bounding box with one query region per node
cluster.

Both survive. The client keeps this branch's request handling and area pruning
and gains their per-row capture time and last-good expiry; their _cache_ts
joins the dicts the areas setter prunes, or it grows for the process lifetime
the way the other three would have. The fetch keeps per-region querying and
gains their cycle wrapper, cache ageing and unit conversions, so cross-
validation runs once per cycle outside the fetch, which is what lets its age
window double as the guarantee that no sample is judged twice.

Their gated cross-validation replaces the log-only stopgap this branch carried
while the penalty was unsafe, since the capture timestamps it was waiting for
now exist. It keeps this branch's pair of position predicates: their inline
(0, 0) test reads a bool as absent, and with the penalty restored a bool
reaching haversine_km would score 8,000 km and charge a truthful node for it.

Their tests move to the region-taking signature with every assertion intact,
and this branch's OpenSky vector helper gains a capture time: it stamped index
3 with 0, which the merged code reads as epoch 0 rather than as absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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