diff --git a/backend/core/state.py b/backend/core/state.py index bce84437..5e19af20 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -420,6 +420,30 @@ def _adsb_for_seeding() -> dict[str, dict]: KNOWN_CLAIMS_PER_HEX_MAX = 64 known_claims: dict[str, deque] = {} +# ── Known-track holds: the link a claim leaves behind ───────────────────────── +# Written by services/known_claiming.py on every claim for (node, hex); read +# by the same module's hold path on the next frame from that node. +# node_id -> hex -> {"delay_us", "doppler_hz", "ts_ms", +# "prev_delay_us", "prev_doppler_hz", "prev_ts_ms", +# "fix": the claim's adsb_fix (original fix_ts_ms), +# "world", "n_claims", "n_hold"} +# A claim is evidence that THIS node's echo of THIS hex sits at that +# (delay, Doppler); the transponder fix that first supplied the identity is +# not needed to keep believing it one frame later. So the entry is the node's +# own measured track of the aircraft, and the hold path predicts the next +# frame's observation from it — which is what keeps a linked track linked +# after the tags stop and the cached fix ages out, instead of falling into the +# dark pool as a fresh ghost beside the aircraft it belongs to. +# Two samples, not one: the Doppler rate needs a difference, and a rate from +# ADS-B would re-introduce the dependency the hold exists to drop. +# Same unlocked discipline as known_claims above (single writer per node — the +# frame worker — dict writes atomic under the GIL). Bounded three ways: +# entries older than KNOWN_HOLD_MAX_GAP_S are dropped when that node's next +# frame is processed, feed_gc.prune_stale_stores prunes per hex for a node +# that stopped sending entirely, and a hold is one entry per (node, hex) that +# ever claimed — the same population known_claims is keyed by. +known_track_holds: dict[str, dict[str, dict]] = {} + # ── Track history: rolling position buffer per aircraft hex ─────────────────── # The TRUE frame. Everything internal compares against it — the speed gate's # reference, the arc-motion log, the jump check, routes/test.py's ground-truth @@ -588,6 +612,16 @@ def _adsb_for_seeding() -> dict[str, dict]: # Claiming-stage exceptions absorbed by frame_processor's fail-open guard. # Nonzero means the known lane is broken and silently contributing nothing. known_claims_errors: int = 0 +# Known-track hold (see known_track_holds above and services/known_claiming.py). +# claims counts detections claimed by the hold path — the ones that would have +# fallen into the dark pool as the tags stopped; expired counts hold entries +# dropped for exceeding KNOWN_HOLD_MAX_GAP_S; dropped_disagree counts holds +# discarded because a FRESH ADS-B fix for the same hex contradicted the held +# track (the ghost-lock guard: the hold may outlive the transponder, never +# disagree with it while it is still reporting). +known_hold_claims: int = 0 +known_hold_expired: int = 0 +known_hold_dropped_disagree: int = 0 # Dark track following (DARK_FOLLOW_MODE) — see services/dark_follow.py. # targets is a GAUGE (the size of the current pseudo-state list, assigned on # every rebuild), the other four are since-boot counters. The funnel reads @@ -1044,6 +1078,7 @@ def _reset_for_tests() -> None: global adsb_seed_frames_autotagged, adsb_capture_ts_fallback global known_claims_made, known_claim_contentions, known_claims_bound global known_claims_errors, known_claims_visibility_rejects, known_claims_world_rejects + global known_hold_claims, known_hold_expired, known_hold_dropped_disagree global dark_follow_targets, dark_follow_claims, dark_follow_inputs global dark_follow_published, dark_follow_dropped, dark_bottomup_shadowed global dark_follow_inelig_cooldown, dark_follow_inelig_no_pos @@ -1084,6 +1119,7 @@ def _reset_for_tests() -> None: multinode_tracks, adsb_aircraft, known_claims, + known_track_holds, track_histories, track_histories_public, track_last_emit, @@ -1148,6 +1184,7 @@ def _reset_for_tests() -> None: known_claims_made = known_claim_contentions = known_claims_bound = 0 known_claims_errors = known_claims_visibility_rejects = 0 known_claims_world_rejects = 0 + known_hold_claims = known_hold_expired = known_hold_dropped_disagree = 0 dark_follow_targets = dark_follow_claims = dark_follow_inputs = 0 dark_follow_published = dark_follow_dropped = 0 dark_follow_inelig_cooldown = dark_follow_inelig_no_pos = 0 diff --git a/backend/routes/test.py b/backend/routes/test.py index 174481df..5652b5e2 100644 --- a/backend/routes/test.py +++ b/backend/routes/test.py @@ -14,7 +14,7 @@ from core import state from core.task_registry import get_stale_tasks from core.users import require_admin -from services import dark_follow, track_filter +from services import dark_follow, known_claiming, track_filter from services.frame_processor import resolve_ground_truth_hex from services.geo import haversine_km from services.id_utils import is_transponder_hex, normalize_hex_key @@ -467,6 +467,36 @@ async def get_anomaly_log(): ) +# ── Known-track hold (path H) ───────────────────────────────────────────────── + + +@router.get("/api/test/known-hold") +async def get_known_hold(): + """Current hold window, in seconds of frame time (0 = feature off).""" + return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S} + + +@router.put("/api/test/known-hold") +async def put_known_hold(body: dict = Body(...), _admin=Depends(require_admin)): + """Set the hold window live, so the feature can be A/B'd on a running + backend without a redeploy. 0 turns path H off entirely — the store stops + being written as well, so "off" is the behaviour that predates the hold + rather than a hold that never matches. + + Admin-gated on the same precedent as put_simulation_config: this changes + which detections leave the dark pool for every node at once. + """ + v = body.get("max_gap_s") + if not isinstance(v, (int, float)) or isinstance(v, bool) or not (0 <= v <= 300): + raise HTTPException(400, detail="max_gap_s must be 0-300") + known_claiming.KNOWN_HOLD_MAX_GAP_S = float(v) + if v == 0: + # Off means off: a store left behind would come back the moment the + # window was reopened, holding tracks from before the experiment. + state.known_track_holds.clear() + return {"max_gap_s": known_claiming.KNOWN_HOLD_MAX_GAP_S} + + # ── Simulation physics config ───────────────────────────────────────────────── @@ -1275,6 +1305,9 @@ def _solver_window_stats(minutes: float) -> dict: kc_visibility_rejects = state.known_claims_visibility_rejects kc_world_rejects = state.known_claims_world_rejects kc_errors = state.known_claims_errors + kh_claims = state.known_hold_claims + kh_expired = state.known_hold_expired + kh_disagree = state.known_hold_dropped_disagree # Same one-lock snapshot for the follow lane's funnel and the # per-reason ineligibility tally beside it: the two are only readable # against each other (see the dark_follow block below), so they must @@ -1442,6 +1475,16 @@ def _solver_window_stats(minutes: float) -> dict: "visibility_rejects": kc_visibility_rejects, "world_rejects": kc_world_rejects, "errors": kc_errors, + # Path H (services/known_claiming._claim_holds). claims is the + # detections held onto a hex after its transponder stopped + # explaining them; disagree the ghost-lock guard firing (a fresh + # fix contradicted the held track); holds the CURRENT size of the + # store, a gauge, summed over nodes — read beside claims, since a + # store that grows while claims does not is holds that never match. + "hold_claims": kh_claims, + "hold_expired": kh_expired, + "hold_dropped_disagree": kh_disagree, + "holds": sum(len(h) for h in list(state.known_track_holds.values())), }, # Dark published solves against the node pool their round had for the # same aircraft (see the pooled/shortfalls block above). pct is null diff --git a/backend/services/aircraft_feed.py b/backend/services/aircraft_feed.py index db62a082..a6e5f9f5 100644 --- a/backend/services/aircraft_feed.py +++ b/backend/services/aircraft_feed.py @@ -39,6 +39,7 @@ normalize_hex_key, passive_track_hex, ) +from services.known_claiming import KNOWN_CLAIM_MAX_FIX_AGE_S from services.public_location import fuzz_node_cfg from services.solve_uncertainty import solve_sigma_m, velocity_sigma_ms from services.track_gates import ( @@ -309,6 +310,19 @@ def _claimed_single_node_entries(now: float) -> list[dict]: lat, lon = fix.get("lat"), fix.get("lon") if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)): continue + # A HELD claim (known_claiming path H) carries the LAST fix the + # transponder ever gave, however old — the hold is a radar link, not a + # position report. This section draws the fix itself, so once that fix + # is past the claiming path's own freshness cap there is nothing here + # worth drawing: the icon would sit where the aircraft was, not where + # it is, and grow more wrong the longer the hold succeeds. Skipped + # rather than dead-reckoned — one node's claim gives an arc, not a + # position, so there is no honest estimate to put in its place. Two or + # more claiming nodes are unaffected: those are the known lane's, and + # it solves them (see known_lane._build_solver_input's stale-fix seed). + _fix_age_s = (float(newest["ts_ms"]) - float(fix.get("fix_ts_ms") or 0)) / 1000.0 + if newest.get("hold") and _fix_age_s > KNOWN_CLAIM_MAX_FIX_AGE_S: + continue node_id = newest["node_id"] delay_us = float(newest.get("delay_us") or 0.0) @@ -341,6 +355,12 @@ def _claimed_single_node_entries(now: float) -> list[dict]: "seen": round(max(0.0, now - float(newest["ts_ms"]) / 1000.0), 1), "multinode": False, "position_source": "adsb_single_node", + # True when the drawn fix is no longer being refreshed by a + # transponder (a hold still inside the freshness cap, or a + # cached fix that has aged during this claim's own lifetime), + # so the map can say the position is coasting rather than + # measured. Absent-as-false for every claim made today. + "adsb_stale": _fix_age_s > 0.0 and bool(newest.get("hold")), # Mandatory: the live/owner WS feeds drop any entry whose # node_id is not in the connection's node set. "node_id": node_id, diff --git a/backend/services/feed_gc.py b/backend/services/feed_gc.py index 7bf88f87..34018bf4 100644 --- a/backend/services/feed_gc.py +++ b/backend/services/feed_gc.py @@ -53,6 +53,19 @@ def prune_stale_stores(now: float) -> None: for h in stale_claims: state.known_claims.pop(h, None) + # 4a-bis. Known-track holds, on the same rule and for the same reason. + # The claiming path expires a node's own entries against ITS frame clock, + # which never ticks again for a node that stopped sending — so a node that + # disconnects mid-claim would pin one entry per hex it ever held. Pruned + # per hex (not per node) so a still-live node keeps the tracks it is still + # holding. + for nid, holds in list(state.known_track_holds.items()): + for h, e in list(holds.items()): + if (now - e.get("ts_ms", 0) / 1000.0) > KNOWN_CLAIMS_STALE_S: + holds.pop(h, None) + if not holds: + state.known_track_holds.pop(nid, None) + # 4b. Prune stale ground-truth trails and track histories to bound # memory and keep resolve_ground_truth_hex O(N) scans cheap. # diff --git a/backend/services/known_claiming.py b/backend/services/known_claiming.py index cf44df71..aaf696d7 100644 --- a/backend/services/known_claiming.py +++ b/backend/services/known_claiming.py @@ -40,10 +40,28 @@ position no radar in either world measured. Entries carry a "world" tag ("sim"/"real") stamped where they are written; claiming skips candidates tagged with the other world and counts them (known_claims_world_rejects). + +THE HOLD (path H). Both ADS-B paths ask the same question every frame from +scratch: does a transponder fix explain this detection right now? When the +tags stop and the cached fix ages past KNOWN_CLAIM_MAX_FIX_AGE_S the answer +becomes "no" for an aircraft that has not moved, changed, or gone anywhere — +its echoes fall into the dark pool, the tracker forms a track, and the dark +solver mints a new key beside (or on top of) the aircraft the lane was +tracking a second earlier. But a claim is evidence in its own right: it says +this node's echo of this hex sat at that (delay, Doppler). The next frame's +echo of the same aircraft is one frame of motion away from it, and Doppler +says how far — so the node's OWN measured track can predict the next +observation without any transponder at all. Path H does exactly that, from +state.known_track_holds, and runs after path 1 and before path 2 so that a +link, once made, cannot be peeled off by another hex's dead-reckoned fix. +It has no maximum duration: as long as the track keeps matching frame after +frame it stays linked. The one thing it may not do is contradict a live +transponder — see the consistency rule in _claim_holds. """ import logging import math +import os from collections import deque import numpy as np @@ -89,6 +107,39 @@ # not tuned to the error, it is large enough that the error cannot reach it. _SCREEN_MARGIN = 1.02 +# ── Hold gates (path H) ────────────────────────────────────────────────────── +# The maximum frame-time gap a hold may bridge, and the feature's rollback +# lever: 0 disables path H entirely and leaves claiming byte-identical to the +# behaviour that predates it. Measured in FRAME time (frame["timestamp"]), +# never wall clock — a replay or a backlogged node must see the same gates the +# live path saw. 8 s: a node's frames arrive ~1 s apart and the simulator's +# SNR-dependent miss rate reaches 40% at the detection threshold, so 2–4 s +# gaps are routine and a bound below that would break the link on ordinary +# misses; past ~8 s the propagated Doppler rate is extrapolation rather than +# measurement. +KNOWN_HOLD_MAX_GAP_S = float(os.getenv("KNOWN_HOLD_MAX_GAP_S", "8")) +# Gate = base + rate * dt, per axis. The bases are set off the measurement +# noise, not off aircraft motion (motion is what the prediction models): +# retina_simulation.world.generate_detections_for_node adds sigma 0.1–0.2 µs +# of delay noise and 2–4 Hz of Doppler noise depending on SNR, and the hold +# compares a prediction built from ONE past sample against a new one, so the +# relevant sigma is sqrt(2) of that — ~0.3 µs and ~5.7 Hz at the noisy end. +# A gate must sit at least 5 sigma above it or ordinary noise breaks the link, +# which puts the bases at 1.5 µs and 20 Hz. The rate terms cover what the +# constant-rate propagation itself cannot: turns and altitude changes, which +# grow with the gap. +KNOWN_HOLD_DELAY_GATE_US = float(os.getenv("KNOWN_HOLD_DELAY_GATE_US", "1.5")) +KNOWN_HOLD_DELAY_RATE_US_PER_S = float(os.getenv("KNOWN_HOLD_DELAY_RATE_US_PER_S", "1.0")) +KNOWN_HOLD_DOPPLER_GATE_HZ = float(os.getenv("KNOWN_HOLD_DOPPLER_GATE_HZ", "20")) +KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S = float(os.getenv("KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S", "10")) +# Ceiling on the propagated Doppler rate, and the maximum age of the sample +# pair it may be derived from. An airliner's bistatic Doppler rate stays well +# inside +-15 Hz/s outside a hard turn; a larger apparent rate is two samples +# of DIFFERENT aircraft (or a miss-riddled pair straddling a manoeuvre), and +# propagating it would walk the prediction off the track it is holding. +KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S = float(os.getenv("KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S", "15")) +KNOWN_HOLD_RATE_MAX_SPAN_S = float(os.getenv("KNOWN_HOLD_RATE_MAX_SPAN_S", "5")) + _logger = logging.getLogger(__name__) # ── services.node_bias (trust slice) — optional at import time ──────────────── @@ -332,6 +383,260 @@ def _claim_dark_follow( return claimed +def _touch_hold( + node_id: str, + hexn: str, + d_meas: float, + f_meas: float, + ts_ms: int, + fix: dict | None, + world: str | None, + is_hold: bool, +) -> None: + """Record this claim as the node's newest measurement of the hex's track. + + Called for EVERY claim — node tag, cached-fix assignment, or hold — because + the store's whole job is to remember that this node's echo of this hex was + here, whichever path established it. Keeps the previous sample beside the + newest one: a Doppler RATE needs a difference, and taking that rate from + ADS-B would put the transponder back in the loop the hold exists to + survive without. + + ``fix`` is None on a hold claim, which is what keeps the ORIGINAL fix (and + its fix_ts_ms) on the entry: downstream readers age the fix to see how long + the aircraft has been silent, and refreshing the timestamp without a new + transponder report would hide exactly that. + """ + if KNOWN_HOLD_MAX_GAP_S <= 0: + # The rollback lever is total: with the feature off nothing is written + # either, so an off backend carries no store and claiming is exactly + # what it was before path H existed. + return + holds = state.known_track_holds.setdefault(node_id, {}) + e = holds.get(hexn) + if e is None: + e = { + "delay_us": d_meas, + "doppler_hz": f_meas, + "ts_ms": ts_ms, + "prev_delay_us": None, + "prev_doppler_hz": None, + "prev_ts_ms": None, + "fix": fix, + "world": world, + "n_claims": 0, + "n_hold": 0, + } + holds[hexn] = e + else: + if ts_ms != e["ts_ms"]: + e["prev_delay_us"] = e["delay_us"] + e["prev_doppler_hz"] = e["doppler_hz"] + e["prev_ts_ms"] = e["ts_ms"] + e["delay_us"] = d_meas + e["doppler_hz"] = f_meas + e["ts_ms"] = ts_ms + if fix is not None: + e["fix"] = fix + e["world"] = world + e["n_claims"] += 1 + if is_hold: + e["n_hold"] += 1 + + +def _hold_predict(entry: dict, fc_hz: float, frame_ts_s: float) -> tuple[float, float, float]: + """(pred_delay_us, pred_doppler_hz, dt_s) for a held track at frame time. + + Delay is propagated from Doppler rather than from a delay difference: + Doppler IS the range rate, measured in this same frame, so it gives a + first-order prediction from a SINGLE past sample (the case that matters — + the frame right after the tags stop) instead of needing two. The bistatic + range closes when the Doppler is positive, so the delay shrinks: + + d(delay_us)/dt = -doppler_hz * 1e6 / fc_hz + + (delay_us = R_km / C_KM_US, dR/dt = -doppler * C_KM_S / fc_hz, and + C_KM_S = C_KM_US * 1e6, so the C_KM_US cancels.) The sign is checked + empirically against the simulator in test_known_track_hold.py rather than + trusted from this derivation — a convention flip anywhere between the + generator and here would double the error instead of cancelling it. + + Doppler is propagated at the rate of the last two samples, clipped, and + only when they are recent enough to be one manoeuvre; otherwise it is held + flat, which is the honest zero-information answer. + """ + dt = frame_ts_s - entry["ts_ms"] / 1000.0 + delay_rate = -entry["doppler_hz"] * 1.0e6 / fc_hz + doppler_rate = 0.0 + prev_ts_ms = entry.get("prev_ts_ms") + if prev_ts_ms is not None: + span = (entry["ts_ms"] - prev_ts_ms) / 1000.0 + if 0.0 < span <= KNOWN_HOLD_RATE_MAX_SPAN_S: + raw = (entry["doppler_hz"] - entry["prev_doppler_hz"]) / span + doppler_rate = max(-KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S, min(KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S, raw)) + return ( + entry["delay_us"] + delay_rate * dt, + entry["doppler_hz"] + doppler_rate * dt, + dt, + ) + + +def _fix_record(st: dict) -> dict: + """The REPORTED fix a claim carries, built from one cached ADS-B state — + same rule as associate_detections_to_adsb, so a claim and a node tag for + one aircraft carry the same position and downstream consumers need not + know which path produced it.""" + return { + "lat": st["lat"], + "lon": st["lon"], + "alt_baro": st.get("alt_baro"), + "gs": st.get("gs"), + "track": st.get("track"), + "fix_ts_ms": st.get("timestamp_ms", 0), + } + + +def _fresh_fix_prediction( + hexn: str, geo, frame_ts_s: float, node_world: str +) -> tuple[float, float, float, dict] | None: + """Path 2's own prediction for one hex plus the fix record it would carry, + or None when no fresh usable fix exists. The consistency rule's + reference — see _claim_holds.""" + st = state._adsb_for_seeding().get(hexn) + if st is None: + return None + cand_world = st.get("world") + if cand_world is not None and cand_world != node_world: + return None + age_s = frame_ts_s - st.get("timestamp_ms", 0) / 1000.0 + if abs(age_s) > KNOWN_CLAIM_MAX_FIX_AGE_S: + return None + dr_lat, dr_lon = offset_latlon_m( + st["lat"], + st["lon"], + east_m=st.get("vel_east", 0.0) * age_s, + north_m=st.get("vel_north", 0.0) * age_s, + ) + pred_d, pred_f = predict_observation( + geo, + dr_lat, + dr_lon, + st.get("alt_m", 0.0) / 1000.0, + st.get("vel_east", 0.0), + st.get("vel_north", 0.0), + ) + return pred_d, pred_f, _gate_scale(age_s), _fix_record(st) + + +def _claim_holds( + node_id: str, + geo, + frame_ts_s: float, + delays: list, + dopplers: list, + free: list[int], + claimed_hexes: set[str], +) -> list[tuple[int, str, dict, float, float, dict]]: + """Path H: claim leftover detections against this node's own held tracks. + + Runs between path 1 and path 2, which is the precedence rule the user's + requirement names: once a node track is linked to a hex it may not be + peeled off to another hex's dead-reckoned fix, and running after path 2 + would let exactly that happen every time a neighbouring aircraft's fix + reached this detection first. Path 1 still outranks it — a node's own + correlation is newer evidence about the same question than yesterday's + match. + + THE CONSISTENCY RULE (the ghost-lock guard). A hold that may never be + contradicted is a self-feeding loop of the kind dark_follow's guard exists + to stop: the hold claims a detection, the claim refreshes the hold, and + nothing can ever disagree because binding mode has already taken the + detection out of the lane that would. So while the transponder is still + reporting, the hold must AGREE with it: if the hex has a fresh cached fix, + the hold-matched detection has to fall inside path 2's gate for that hex + too, or the hold is dropped and the hex falls through to path 2 as it does + today. When the fix is stale or gone there is nothing to disagree with, + and the hold stands on its own — which is the entire point of the feature. + """ + if KNOWN_HOLD_MAX_GAP_S <= 0: + return [] + holds = state.known_track_holds.get(node_id) + if not holds: + return [] + + # Expiry first, on this node's own frame clock, so a held track that has + # gone quiet for longer than the gap can bridge is gone before it can be + # matched (and cannot linger in the store either). + expired = [ + h for h, e in list(holds.items()) if not (0.0 <= frame_ts_s - e["ts_ms"] / 1000.0 <= KNOWN_HOLD_MAX_GAP_S) + ] + for h in expired: + holds.pop(h, None) + if expired: + state.bump_counter("known_hold_expired", len(expired)) + if not free or not holds: + return [] + + cands = [] + for hexn, e in list(holds.items()): + if hexn in claimed_hexes: + continue + pred_d, pred_f, dt = _hold_predict(e, geo.fc_hz, frame_ts_s) + d_gate = KNOWN_HOLD_DELAY_GATE_US + KNOWN_HOLD_DELAY_RATE_US_PER_S * dt + f_gate = KNOWN_HOLD_DOPPLER_GATE_HZ + KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S * dt + cands.append((hexn, e, pred_d, pred_f, d_gate, f_gate, dt)) + if not cands: + return [] + + cost = np.full((len(free), len(cands)), _GATE_INFEASIBLE) + for c, (_hexn, _e, pred_d, pred_f, d_gate, f_gate, _dt) in enumerate(cands): + for r, i in enumerate(free): + d_res = abs(pred_d - float(delays[i])) + f_res = abs(pred_f - float(dopplers[i])) + if d_res > d_gate or f_res > f_gate: + continue + cost[r, c] = d_res / d_gate + f_res / f_gate + rows, cols = linear_sum_assignment(cost) + + node_world = state.node_world(node_id) + out: list[tuple[int, str, dict, float, float, dict]] = [] + for r, c in zip(rows, cols): + if cost[r, c] >= _GATE_INFEASIBLE: + continue + i = free[r] + hexn, e, pred_d, pred_f, _d_gate, _f_gate, dt = cands[c] + ref = _fresh_fix_prediction(hexn, geo, frame_ts_s, node_world) + extra = {"hold": True, "hold_gap_s": round(dt, 3)} + if ref is not None: + ref_d, ref_f, scale, fresh_fix = ref + if ( + abs(ref_d - float(delays[i])) > KNOWN_CLAIM_DELAY_GATE_US * scale + or abs(ref_f - float(dopplers[i])) > KNOWN_CLAIM_DOPPLER_GATE_HZ * scale + ): + holds.pop(hexn, None) + state.bump_counter("known_hold_dropped_disagree") + continue + # The transponder is live and agrees, so the claim carries THAT + # fix, exactly as path 2 would have — and refreshes the hold with + # it (see the caller). Without this, path H outranking path 2 + # every frame would freeze the entry's fix at the first claim on + # a node without tags, and the lane would read a live aircraft + # as 45 s silent. + fix = fresh_fix + extra["fix_refreshed"] = True + else: + fix = e.get("fix") + if not isinstance(fix, dict): + # A hold with no fix behind it has nothing to seed the known lane + # with, and every reader of a claim keys on adsb_fix. Cannot + # happen from the paths above; dropped rather than published as a + # half-claim. + continue + out.append((i, hexn, fix, pred_d, pred_f, extra)) + state.bump_counter("known_hold_claims") + return out + + def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | None = None) -> set[int]: """Run the claiming stage for one frame; return the claimed detection indices. @@ -346,6 +651,11 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No backend never overwrites a node-provided list), so it is not re-gated; the prediction is still computed so the record carries the residual the trust path needs. + H. This node's own HELD tracks (state.known_track_holds): hexes this + node has claimed before, predicted forward from their last measured + (delay, Doppler) rather than from a transponder fix. Ahead of path 2 + so a linked track cannot be peeled off to another hex, and therefore + ahead of path 3 as well. See _claim_holds. 2. Remaining detections × fresh cached ADS-B states whose dead-reckoned position this node can see, global one-to-one via linear_sum_assignment under age-scaled gates. @@ -375,8 +685,8 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No ts_ms = int(frame.get("timestamp", 0)) frame_ts_s = ts_ms / 1000.0 - # (det_idx, hexn, adsb_fix, pred_delay_us, pred_doppler_hz) - claims: list[tuple[int, str, dict, float, float]] = [] + # (det_idx, hexn, adsb_fix, pred_delay_us, pred_doppler_hz, extra) + claims: list[tuple[int, str, dict, float, float, dict]] = [] claimed_idx: set[int] = set() claimed_hexes: set[str] = set() @@ -408,10 +718,25 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No "track": tag.get("track"), "fix_ts_ms": ts_ms, } - claims.append((i, hexn, fix, pred_d, pred_f)) + claims.append((i, hexn, fix, pred_d, pred_f, {})) claimed_idx.add(i) claimed_hexes.add(hexn) + # ── Path H: this node's own held tracks ────────────────────────────────── + # Between path 1 and path 2 on purpose — see _claim_holds. + for hold_claim in _claim_holds( + node_id, + geo, + frame_ts_s, + delays, + dopplers, + [i for i in range(len(delays)) if i not in claimed_idx], + claimed_hexes, + ): + claims.append(hold_claim) + claimed_idx.add(hold_claim[0]) + claimed_hexes.add(hold_claim[1]) + # ── Path 2: assignment over untagged detections × fresh cached states ──── free = [i for i in range(len(delays)) if i not in claimed_idx] if free: @@ -539,21 +864,10 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No ( i, hexn, - { - # REPORTED fix, not the dead-reckoned one — same - # rule as associate_detections_to_adsb, so a claim - # and a node tag for one aircraft carry the same - # position and downstream consumers need not know - # which path produced it. - "lat": st["lat"], - "lon": st["lon"], - "alt_baro": st.get("alt_baro"), - "gs": st.get("gs"), - "track": st.get("track"), - "fix_ts_ms": st.get("timestamp_ms", 0), - }, + _fix_record(st), pred_d, pred_f, + {}, ) ) claimed_idx.add(i) @@ -561,7 +875,8 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No # ── Contention, registry, counters, residual hook ───────────────────────── projections = _dark_global_projections(geo, frame_ts_s) if claims else [] nb = _node_bias() if claims else None - for i, hexn, fix, pred_d, pred_f in claims: + node_world_tag = state.node_world(node_id) if claims else None + for i, hexn, fix, pred_d, pred_f, extra in claims: d_meas = float(delays[i]) f_meas = float(dopplers[i]) # A detection both a known hex and an established dark track can @@ -584,8 +899,25 @@ def claim_known_targets(node_id: str, frame: dict, follow_claimed: set[int] | No "ts_ms": ts_ms, "adsb_fix": fix, "contested": contested, + # "hold": True / "hold_gap_s" on a path-H claim; absent + # otherwise, so every existing reader is unchanged. + **extra, } ) + # Every claim is a fresh measurement of this node's track of this hex, + # whichever path made it — that is what the hold store holds. A hold + # claim passes fix=None so the stored (older) fix and its fix_ts_ms + # survive, keeping the silence visible downstream. + _touch_hold( + node_id, + hexn, + d_meas, + f_meas, + ts_ms, + None if extra.get("hold") and not extra.get("fix_refreshed") else fix, + node_world_tag, + bool(extra.get("hold")), + ) state.bump_counter("known_claims_made") if contested: state.bump_counter("known_claim_contentions") diff --git a/backend/services/tasks/known_lane.py b/backend/services/tasks/known_lane.py index 8e9754c1..bfaaf47c 100644 --- a/backend/services/tasks/known_lane.py +++ b/backend/services/tasks/known_lane.py @@ -69,6 +69,7 @@ from services import dark_follow, track_filter from services.geo import haversine_km, offset_latlon_m from services.id_utils import normalize_hex_key +from services.known_claiming import KNOWN_CLAIM_MAX_FIX_AGE_S # Deliberate one-way dependency: this module reuses the solver worker's # record store, gates, publication lock and smoother so known-lane records @@ -257,6 +258,45 @@ def _build_solver_input(hexn: str, claims: dict[str, dict]) -> dict | None: east_m=vel_east * dt_s, north_m=vel_north * dt_s, ) + seed_source = "fix" + + # A HELD claim (known_claiming path H) can outlive its transponder by any + # amount — that is the point of the hold — and the fix above is then a + # position from minutes ago propagated at a heading from minutes ago. Its + # dead-reckoned guess drifts kilometres, _attempt measures displacement + # against it, labels the honest solve a "ghost", and the aircraft stops + # being published exactly when it stopped being visible any other way. + # Past the fix-age cap the lane's OWN last solve for this hex is the better + # prior: it is a radar measurement, seconds old, of the same aircraft, and + # the filter has a velocity for it. Altitude still comes from the fix — + # a barometric altitude does not go stale the way a position does, and it + # remains the best altitude information anyone has. + if abs(dt_s) > KNOWN_CLAIM_MAX_FIX_AGE_S: + prev = state.multinode_tracks.get(f"mn-adsb-{hexn}") + prev_lat = prev.get("lat") if isinstance(prev, dict) else None + prev_lon = prev.get("lon") if isinstance(prev, dict) else None + prev_ts_ms = _num(prev.get("timestamp_ms"), 0) if isinstance(prev, dict) else 0 + if isinstance(prev_lat, (int, float)) and isinstance(prev_lon, (int, float)) and prev_ts_ms: + learned = track_filter.learned_velocity(f"mn-adsb-{hexn}") + if learned is not None: + seed_ve, seed_vn = float(learned[0]), float(learned[1]) + else: + seed_ve = _num(prev.get("vel_east")) + seed_vn = _num(prev.get("vel_north")) + coast_s = (newest_ts_ms - int(prev_ts_ms)) / 1000.0 + guess_lat, guess_lon = offset_latlon_m( + float(prev_lat), + float(prev_lon), + east_m=seed_ve * coast_s, + north_m=seed_vn * coast_s, + ) + # The velocity seed moves with the guess: seeding a position from + # the radar track and a heading from a stale transponder report + # would be two different aircraft's worth of prior. + vel_east, vel_north = seed_ve, seed_vn + seed_source = "kf" + # No prior solve leaves seed_source "fix" and today's dead-reckoned guess: + # a drifting prior still beats no prior, and the classification says so. return { "initial_guess": { @@ -264,6 +304,7 @@ def _build_solver_input(hexn: str, claims: dict[str, dict]) -> dict | None: "lon": guess_lon, "alt_km": _num(fix.get("alt_baro")) * FT_TO_M / 1000.0, }, + "seed_source": seed_source, "initial_velocity": { "vel_east_ms": vel_east, "vel_north_ms": vel_north, @@ -423,7 +464,13 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non "known_no_converge", s_in, result if isinstance(result, dict) else None, - extra={"known_lane": True, "label": "no_converge", "published": False, **epoch_meta}, + extra={ + "known_lane": True, + "label": "no_converge", + "published": False, + "seed_source": s_in.get("seed_source", "fix"), + **epoch_meta, + }, ) return @@ -463,7 +510,13 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non raw_lat=raw_lat, raw_lon=raw_lon, displacement_km=err_km, - extra={"known_lane": True, "label": label, "published": published, **epoch_meta}, + extra={ + "known_lane": True, + "label": label, + "published": published, + "seed_source": s_in.get("seed_source", "fix"), + **epoch_meta, + }, ) diff --git a/backend/tests/test_known_track_hold.py b/backend/tests/test_known_track_hold.py new file mode 100644 index 00000000..01d241e4 --- /dev/null +++ b/backend/tests/test_known_track_hold.py @@ -0,0 +1,421 @@ +"""Known-track HOLD (path H) — services/known_claiming._claim_holds, +state.known_track_holds, and known_lane's stale-fix seed. + +Same conventions as test_known_claiming.py: a real associator geometry +registered per test, ADS-B injected through state.adsb_aircraft, claiming +driven frame by frame. + +The requirement under test: once a node track is linked to an ADS-B hex the +link survives the transponder going quiet — subsequent detections in that +track stay claimed, and no other hex's dead-reckoned fix can peel them off. +""" + +import random +import time + +import pytest +from retina_analytics.association import predict_observation +from retina_simulation.world import NodeConfig as SimNodeConfig +from retina_simulation.world import SimulationWorld + +from config.constants import FT_TO_M +from core import state +from services import known_claiming as kc +from services.tasks import known_lane + +_NODE_CFG = { + "rx_lat": 34.85, + "rx_lon": -82.40, + "rx_alt_ft": 1000, + "tx_lat": 34.9412, + "tx_lon": -82.4103, + "tx_alt_ft": 2000, + "fc_hz": 183e6, + "beam_width_deg": 90, + "max_range_km": 60, + "beam_azimuth_deg": 45.0, +} +_NODE_ID = "test-known-hold" +_LAT, _LON = 34.88, -82.35 +_ALT_BARO_FT = 7000.0 / FT_TO_M +_HEX = "abc123" + + +@pytest.fixture(autouse=True) +def _clean(): + state.known_claims.clear() + state.known_track_holds.clear() + state.adsb_aircraft.clear() + state.multinode_tracks.clear() + yield + state.known_claims.clear() + state.known_track_holds.clear() + state.adsb_aircraft.clear() + state.multinode_tracks.clear() + + +def _register(node_id=_NODE_ID): + state.node_associator.register_node(node_id, _NODE_CFG) + return state.node_associator.node_geometries[node_id] + + +def _frame(ts_ms, delays, dopplers, adsb=None): + f = { + "timestamp": ts_ms, + "delay": list(delays), + "doppler": list(dopplers), + "snr": [20.0] * len(delays), + } + if adsb is not None: + f["adsb"] = adsb + return f + + +def _tag(lat=_LAT, lon=_LON, gs=0.0, track=0.0): + return {"hex": _HEX, "lat": lat, "lon": lon, "alt_baro": _ALT_BARO_FT, "gs": gs, "track": track} + + +def _pred(geo, lat=_LAT, lon=_LON, ve=0.0, vn=0.0): + return predict_observation(geo, lat, lon, _ALT_BARO_FT * FT_TO_M / 1000.0, ve, vn) + + +class TestHoldPath: + def test_tagged_frame_then_silence_keeps_the_claim(self): + """1. Path-1 claim on frame 1; frame 2 carries no tag and the cache is + empty — the detection is claimed anyway, marked hold.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + assert kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) == {0} + assert state.known_track_holds[_NODE_ID][_HEX]["delay_us"] == pytest.approx(d) + + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) + assert claimed == {0} + rec = state.known_claims[_HEX][-1] + assert rec["hold"] is True + assert rec["hold_gap_s"] == pytest.approx(1.0) + # The STORED fix, with its original epoch, so the silence is visible. + assert rec["adsb_fix"]["fix_ts_ms"] == ts + + def test_fresh_fix_that_agrees_refreshes_the_hold(self): + """A live transponder that AGREES with the held track is carried by + the hold claim and refreshes the store: on a node without tags path H + outranks path 2 every frame, and without this the entry's fix would + freeze at the first claim and read a live aircraft as silent.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + state.adsb_aircraft[_HEX] = { + "hex": _HEX, + "lat": _LAT, + "lon": _LON, + "alt_baro": _ALT_BARO_FT, + "gs": 0, + "track": 0, + "timestamp_ms": ts + 1000, + "last_seen_ms": ts + 1000, + } + before = state.known_hold_dropped_disagree + assert kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) == {0} + assert state.known_hold_dropped_disagree == before + rec = state.known_claims[_HEX][-1] + assert rec["hold"] is True + assert rec["fix_refreshed"] is True + assert rec["adsb_fix"]["fix_ts_ms"] == ts + 1000 + assert state.known_track_holds[_NODE_ID][_HEX]["fix"]["fix_ts_ms"] == ts + 1000 + + def test_gap_beyond_the_window_expires_the_hold(self): + """2. A gap longer than KNOWN_HOLD_MAX_GAP_S drops the entry.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + before = state.known_hold_expired + + gap_ms = int((kc.KNOWN_HOLD_MAX_GAP_S + 2.0) * 1000) + assert kc.claim_known_targets(_NODE_ID, _frame(ts + gap_ms, [d], [f])) == set() + assert _HEX not in state.known_track_holds.get(_NODE_ID, {}) + assert state.known_hold_expired == before + 1 + + def test_fresh_fix_that_disagrees_drops_the_hold(self): + """3. The ghost-lock guard: a live transponder that puts the aircraft + somewhere else wins, and the hex falls through to path 2.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + before = state.known_hold_dropped_disagree + + # Fresh cached fix for the SAME hex, far from the held track. + moved_lat, moved_lon = 34.94, -82.28 + state.adsb_aircraft[_HEX] = { + "hex": _HEX, + "lat": moved_lat, + "lon": moved_lon, + "alt_baro": _ALT_BARO_FT, + "gs": 0, + "track": 0, + "last_seen_ms": ts + 1000, + } + d2, f2 = _pred(geo, lat=moved_lat, lon=moved_lon) + assert abs(d2 - d) > kc.KNOWN_HOLD_DELAY_GATE_US + kc.KNOWN_HOLD_DELAY_RATE_US_PER_S + + # The detection is where the HOLD predicts, not where the fix does. + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) + assert state.known_hold_dropped_disagree == before + 1 + assert claimed == set() # path 2's gate rejects it too — correctly + assert _HEX not in state.known_track_holds.get(_NODE_ID, {}) + + def test_hold_precedes_dark_follow(self, monkeypatch): + """4. A dark-follow target predicting the same detection does not get + it: path H claims first, and path 3 only ever sees the leftovers.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) + + monkeypatch.setattr(kc.dark_follow, "mode", lambda: "binding") + monkeypatch.setattr( + kc.dark_follow, + "follow_targets", + lambda: [ + { + "key": "mn-dark-zzz", + "lat": _LAT, + "lon": _LON, + "alt_m": _ALT_BARO_FT * FT_TO_M, + "vel_east": 0.0, + "vel_north": 0.0, + "timestamp_ms": ts + 1000, + "world": None, + } + ], + ) + followed: set[int] = set() + claimed = kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f]), follow_claimed=followed) + assert claimed == {0} + assert followed == set() + assert "mn-dark-zzz" not in state.known_claims + + def test_zero_gap_is_the_rollback_lever(self, monkeypatch): + """5. KNOWN_HOLD_MAX_GAP_S = 0 reproduces pre-feature behaviour: no + hold claim, and no store either.""" + geo = _register() + ts = int(time.time() * 1000) + d, f = _pred(geo) + monkeypatch.setattr(kc, "KNOWN_HOLD_MAX_GAP_S", 0.0) + assert kc.claim_known_targets(_NODE_ID, _frame(ts, [d], [f], adsb=[_tag()])) == {0} + assert state.known_track_holds == {} + assert kc.claim_known_targets(_NODE_ID, _frame(ts + 1000, [d], [f])) == set() + assert len(state.known_claims[_HEX]) == 1 + + +class TestDelayRatePhysics: + def test_delay_rate_sign_against_the_simulator(self): + """The sign of d(delay)/dt = -doppler * 1e6 / fc, checked against the + simulator's own generator rather than the derivation: a convention flip + anywhere upstream would double the error instead of cancelling it.""" + # Seeded for the same reason the integration test is: the sample count + # this asserts over is the simulator's spawn draw. + random.seed(20260908) + world = SimulationWorld(center_lat=34.0, center_lon=-84.0) + node = SimNodeConfig( + node_id="sim-hold-1", + rx_lat=33.939, + rx_lon=-84.651, + tx_lat=33.756, + tx_lon=-84.331, + beam_width_deg=90, + max_range_km=120, + ) + world.add_node(node) + for _ in range(20): + world.step(1.0, mode="adsb") + + # Noise-free consecutive observations of each aircraft, straight from + # the generator's own physics helpers. + from retina_simulation import world as wmod + + rx_alt_km = node.rx_alt_ft * 0.3048 / 1000.0 + tx_enu = wmod._lla_to_enu( + node.tx_lat, node.tx_lon, node.tx_alt_ft * 0.3048 / 1000.0, node.rx_lat, node.rx_lon, rx_alt_km + ) + + def obs(ac): + t_enu = wmod._lla_to_enu(ac.lat, ac.lon, ac.alt_km, node.rx_lat, node.rx_lon, rx_alt_km) + return ( + wmod._bistatic_delay(t_enu, tx_enu, (0.0, 0.0, 0.0)), + wmod._bistatic_doppler( + t_enu, (ac.vel_east, ac.vel_north, ac.vel_up), tx_enu, (0.0, 0.0, 0.0), node.fc_hz + ), + ) + + before = {id(ac): obs(ac) for ac in world.aircraft} + world.step(1.0, mode="adsb") + checked = 0 + for ac in world.aircraft: + if id(ac) not in before: + continue + d0, f0 = before[id(ac)] + d1, _f1 = obs(ac) + measured_rate = d1 - d0 # µs per 1 s step + predicted_rate = -f0 * 1.0e6 / node.fc_hz + if abs(measured_rate) < 0.05: + continue # too slow to have a sign worth asserting + assert measured_rate * predicted_rate > 0, "delay-rate sign disagrees with Doppler" + assert predicted_rate == pytest.approx(measured_rate, abs=0.15 + 0.2 * abs(measured_rate)) + checked += 1 + assert checked >= 3 + + +class TestSimulationIntegration: + def test_link_survives_the_transponder_going_quiet(self): # noqa: C901 + """6. One node, the simulator's own aircraft and clutter: 30 s of tags, + then 90 s of silence. The hold keeps the aircraft's detections claimed + and never claims clutter.""" + # Seeded: the simulator draws spawn poses, misses and noise from the + # global RNG, which any earlier test in the run may have advanced. A + # rate assertion has to be measured against one fixed scenario or it is + # measuring the run order. + random.seed(20260908) + world = SimulationWorld(center_lat=34.85, center_lon=-82.40) + node_id = "sim-hold-node" + world.add_node( + SimNodeConfig( + node_id=node_id, + rx_lat=_NODE_CFG["rx_lat"], + rx_lon=_NODE_CFG["rx_lon"], + tx_lat=_NODE_CFG["tx_lat"], + tx_lon=_NODE_CFG["tx_lon"], + beam_width_deg=120, + max_range_km=120, + ) + ) + state.node_associator.register_node( + node_id, + { + **_NODE_CFG, + "beam_width_deg": 120, + "max_range_km": 120, + "fc_hz": world.nodes[node_id].fc_hz, + "beam_azimuth_deg": world.nodes[node_id].beam_azimuth_deg, + }, + ) + for _ in range(30): + world.step(1.0, mode="adsb") + # One transponder-carrying aircraft the node can see, and only it. + target = None + for ac in world.aircraft: + if ac.has_adsb and world._aircraft_in_detection_cone(ac, world.nodes[node_id]): + target = ac + break + if target is None: + pytest.skip("no ADS-B aircraft in this node's cone") + for ac in world.aircraft: + if ac is not target: + ac.has_adsb = False + + ts = int(time.time() * 1000) + # 30 s with tags. + for k in range(30): + world.step(1.0, mode="adsb") + frame = world.generate_detections_for_node(node_id, ts + k * 1000) + kc.claim_known_targets(node_id, frame) + assert state.known_track_holds.get(node_id, {}).get(target.adsb_hex) is not None + + # 90 s of silence: no tag, no cache updates. + target.has_adsb = False + state.adsb_aircraft.clear() + detected = 0 + held = 0 + clutter_claims = 0 + for k in range(30, 120): + world.step(1.0, mode="adsb") + ts_ms = ts + k * 1000 + frame = world.generate_detections_for_node(node_id, ts_ms) + # Which detection (if any) is the target's, from truth. + truth_i = _truth_index(world, node_id, target, frame) + before = len(state.known_claims.get(target.adsb_hex, ())) + claimed = kc.claim_known_targets(node_id, frame) + after = len(state.known_claims.get(target.adsb_hex, ())) + if truth_i is not None: + detected += 1 + if truth_i in claimed: + held += 1 + if after > before and truth_i not in claimed: + clutter_claims += 1 + + assert detected >= 20, f"target barely detected ({detected} frames)" + assert held / detected >= 0.85, f"held {held}/{detected}" + assert clutter_claims == 0 + + +def _truth_index(world, node_id, target, frame): + """Index of the target's own detection in this frame, from the simulator's + noise-free geometry — the frame carries no identity once has_adsb is off.""" + from retina_simulation import world as wmod + + node = world.nodes[node_id] + if not world._aircraft_in_detection_cone(target, node): + return None + rx_alt_km = node.rx_alt_ft * 0.3048 / 1000.0 + tx_enu = wmod._lla_to_enu( + node.tx_lat, node.tx_lon, node.tx_alt_ft * 0.3048 / 1000.0, node.rx_lat, node.rx_lon, rx_alt_km + ) + t_enu = wmod._lla_to_enu(target.lat, target.lon, target.alt_km, node.rx_lat, node.rx_lon, rx_alt_km) + d = wmod._bistatic_delay(t_enu, tx_enu, (0.0, 0.0, 0.0)) + f = wmod._bistatic_doppler( + t_enu, (target.vel_east, target.vel_north, target.vel_up), tx_enu, (0.0, 0.0, 0.0), node.fc_hz + ) + best, best_i = None, None + for i, (dd, ff) in enumerate(zip(frame["delay"], frame["doppler"])): + score = abs(dd - d) / 1.0 + abs(ff - f) / 15.0 + if abs(dd - d) < 1.0 and abs(ff - f) < 15.0 and (best is None or score < best): + best, best_i = score, i + return best_i + + +class TestKnownLaneStaleSeed: + def _claims(self, fix_age_s, ts_ms): + fix = { + "lat": _LAT, + "lon": _LON, + "alt_baro": _ALT_BARO_FT, + "gs": 0.0, + "track": 0.0, + "fix_ts_ms": ts_ms - int(fix_age_s * 1000), + } + return { + f"n{i}": {"node_id": f"n{i}", "delay_us": 100.0 + i, "doppler_hz": 5.0, "ts_ms": ts_ms, "adsb_fix": fix} + for i in range(2) + } + + def test_fresh_fix_seeds_from_the_fix(self): + ts = int(time.time() * 1000) + s_in = known_lane._build_solver_input(_HEX, self._claims(5.0, ts)) + assert s_in["seed_source"] == "fix" + assert s_in["initial_guess"]["lat"] == pytest.approx(_LAT) + + def test_stale_fix_seeds_from_the_lanes_own_solve(self): + ts = int(time.time() * 1000) + state.multinode_tracks[f"mn-adsb-{_HEX}"] = { + "lat": 34.90, + "lon": -82.30, + "vel_east": 100.0, + "vel_north": 0.0, + "timestamp_ms": ts - 2000, + } + s_in = known_lane._build_solver_input(_HEX, self._claims(600.0, ts)) + assert s_in["seed_source"] == "kf" + assert s_in["initial_guess"]["lat"] == pytest.approx(34.90, abs=1e-4) + # Coasted 2 s east at 100 m/s from the previous solve. + assert s_in["initial_guess"]["lon"] > -82.30 + # Altitude still comes from the fix. + assert s_in["initial_guess"]["alt_km"] == pytest.approx(_ALT_BARO_FT * FT_TO_M / 1000.0) + assert s_in["initial_velocity"]["vel_east_ms"] == pytest.approx(100.0) + + def test_stale_fix_without_a_prior_solve_falls_back(self): + ts = int(time.time() * 1000) + s_in = known_lane._build_solver_input(_HEX, self._claims(600.0, ts)) + assert s_in["seed_source"] == "fix" diff --git a/backend/tests/test_solver_stats.py b/backend/tests/test_solver_stats.py index 19f38c22..3e9d9814 100644 --- a/backend/tests/test_solver_stats.py +++ b/backend/tests/test_solver_stats.py @@ -446,6 +446,10 @@ def test_known_claims_reflects_the_claiming_counters(self): "visibility_rejects": 6, "world_rejects": 3, "errors": 1, + "hold_claims": 0, + "hold_expired": 0, + "hold_dropped_disagree": 0, + "holds": 0, } def test_both_blocks_zero_on_a_fresh_process(self): @@ -459,6 +463,10 @@ def test_both_blocks_zero_on_a_fresh_process(self): "visibility_rejects": 0, "world_rejects": 0, "errors": 0, + "hold_claims": 0, + "hold_expired": 0, + "hold_dropped_disagree": 0, + "holds": 0, } def test_lane_counters_absent_from_state_read_as_zero(self, monkeypatch): @@ -671,6 +679,10 @@ def test_known_lane_and_known_claims_blocks_present(self): "visibility_rejects", "world_rejects", "errors", + "hold_claims", + "hold_expired", + "hold_dropped_disagree", + "holds", } def test_minutes_clamp_low(self): diff --git a/docs/solverflow.md b/docs/solverflow.md index 23549da1..f3fa4b5a 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -143,6 +143,27 @@ load-bearing, not incidental: claiming (2.3) runs **before** ADS-B seeding and both run **before** the tracker (2.5) so that, in `binding` mode, a claimed detection never reaches the dark-lane tracker or association at all — see the ordering comment at the head of `process_one_frame`'s claiming step. + +**Path H (the hold).** Paths 1 and 2 re-ask every frame whether a transponder +fix explains a detection, so when the tags stop and the cached fix ages past +`KNOWN_CLAIM_MAX_FIX_AGE_S` an aircraft that has done nothing unusual falls +into the dark pool and comes back as a freshly-minted `mn-dark-*` ghost beside +itself. A claim is evidence on its own terms — this node's echo of this hex +sat at that (delay, Doppler) — so `state.known_track_holds` keeps the last two +samples per (node, hex) and path H predicts the next frame from them, delay +propagated from the measured Doppler (`d(delay_us)/dt = -doppler_hz * 1e6 / +fc_hz`). It runs **after path 1 and before path 2**, which is the requirement +rather than an implementation detail: a linked track must not be peelable to +another hex's dead-reckoned fix. There is no maximum hold duration — as long +as the track keeps matching, it stays linked — but a hold may never contradict +a live transponder: with a fresh fix on file the matched detection must pass +path 2's gate for that hex too, or the hold is dropped +(`known_hold_dropped_disagree`). Downstream, `known_lane._build_solver_input` +seeds a stale-fix solve from the lane's own last published `mn-adsb-` +solve instead of the drifting dead-reckoned fix (`seed_source: "kf"`), and +`aircraft_feed._claimed_single_node_entries` skips a singly-claimed hold whose +fix has aged out rather than drawing the aircraft at a position nothing +measured. Frame-level gates (A/B/C on TCP, plus the connected-node check on the v1 API) sit ahead of everything else; nothing downstream sees a frame that failed one of them. @@ -167,6 +188,7 @@ flowchart TD entry -->|"no"| dark0["untouched -> dark lane"]:::inert entry -->|"yes, but exception"| failopen["known_claims_errors
FAIL OPEN to dark lane"]:::inert entry -->|"yes"| path1["Path 1: node-tagged
frame['adsb'] index-aligned"] + entry --> pathH["Path H: Hungarian over
this node's HELD tracks
(state.known_track_holds)"] entry --> path2["Path 2: Hungarian over
cached ADS-B (state._adsb_for_seeding)"] path1 --> gP1{"dict, normalizable hex,
hex unclaimed, finite lat/lon"} @@ -185,6 +207,16 @@ flowchart TD gGate -->|"infeasible"| infeasible["cost = 1.0e6, excluded
by linear_sum_assignment"]:::inert gGate -->|"feasible"| claim2["claim recorded
(REPORTED ADS-B position,
not the DR position)"] + pathH --> gGap{"frame-time gap
<= KNOWN_HOLD_MAX_GAP_S 8s"} + gGap -->|"no"| expired["entry dropped,
known_hold_expired"]:::inert + gGap -->|"yes"| predH["predict from the node's OWN
last claim: delay += -doppler*1e6/fc * dt,
doppler += clipped rate * dt"] + predH --> gGateH{"gate = 1.5us + 1.0us/s * dt,
20Hz + 10Hz/s * dt"} + gGateH -->|"infeasible"| infeasible + gGateH -->|"feasible"| gAgree{"fresh cached fix for this hex?
must ALSO pass path 2's gate"} + gAgree -->|"disagrees"| dropH["hold dropped,
known_hold_dropped_disagree;
hex falls through to path 2"]:::inert + gAgree -->|"agrees, or fix stale/absent"| claimH["claim recorded, hold=True,
hold_gap_s, STORED fix
(original fix_ts_ms)"] + + claimH --> contest claim1 --> contest{"Contention check
vs claim_eligible dark tracks
(n_nodes>=3 OR solve_count>=2)"} claim2 --> contest contest -->|"residual within gate"| contested["flagged + counted,
dark track kept"] @@ -286,6 +318,9 @@ LM's SNR weighting maps to a uniform weight of 1.0. | `CLAIM_MAX_DR_AGE_S` (contention DR window) | 30.0 s | `association.py` | | `CLAIM_ELIGIBLE_MIN_N_NODES` / `MIN_SOLVE_COUNT` | 3 / 2 | `association.py` | | `KNOWN_CLAIMS_PER_HEX_MAX` | 64 | `core/state.py` | +| `KNOWN_HOLD_MAX_GAP_S` (path H window; **0 = feature off**, live-settable via `PUT /api/test/known-hold`) | 8.0 s of frame time | `known_claiming.py` | +| Path H gates: `KNOWN_HOLD_DELAY_GATE_US` + `KNOWN_HOLD_DELAY_RATE_US_PER_S` * dt / `KNOWN_HOLD_DOPPLER_GATE_HZ` + `KNOWN_HOLD_DOPPLER_RATE_HZ_PER_S` * dt | 1.5 us + 1.0 us/s / 20 Hz + 10 Hz/s | `known_claiming.py` | +| `KNOWN_HOLD_MAX_DOPPLER_RATE_HZ_S` / `KNOWN_HOLD_RATE_MAX_SPAN_S` | 15 Hz/s / 5.0 s | `known_claiming.py` | | `_PASS_MIN_INTERVAL_S` | 2.0 s | `services/tasks/known_lane.py` | | `_CLAIM_MAX_AGE_S` / `_CLAIM_SPREAD_S` | 45.0 s / 5.0 s | `known_lane.py` | | `_ATTEMPT_TTL_S` | 600 s | `known_lane.py` |