Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions backend/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,39 @@ def _assoc_alt_layers_km() -> tuple[float, ...]:
# timestamps.
CAL_FIX_DETECTION_SKEW_S = 2.0

# ── Calibration from the CLAIM lane (KNOWN_LANE_MODE != off) ─────────────────
# Since KNOWN_LANE_MODE defaulted to "binding" (#240, 2026-08-25) the emit-loop
# path above records nothing for a synthetic node: claiming strips the bound
# detections from the frame before the tracker sees them, so
# track.last_detection_adsb_hex never gets set and every synthetic node's
# newest calibration point is dated 2026-08-25. The claim lane is now the one
# source under that mode (services/known_claiming.py), under a rule much
# stricter than claiming itself.
#
# Residual bound for a claim that may characterize coverage, UNSCALED by fix
# age (unlike the claim gate itself). The claim gate is 10 µs / 25 Hz, which
# is where it has to be to bind an echo at all; the simulator's measurement
# noise is sigma 0.1–0.2 µs of delay and 2–4 Hz of Doppler
# (retina_simulation.world.generate_detections_for_node), so 3 µs / 8 Hz is
# still >5 sigma at the noisy end while shrinking the delay × Doppler area a
# WRONG aircraft can land in by ~10x. A claim is a bind; a calibration point
# is a claim this node would bet its coverage polygon on.
CAL_CLAIM_DELAY_US = 3.0
CAL_CLAIM_DOPPLER_HZ = 8.0

# Maturity bar, the counterpart of the emit path's ``n_detections >= 3``: a
# one-frame coincidence between a wrong hex's dead-reckoned fix and a clutter
# peak is exactly what a tight residual cannot rule out on its own, and it
# cannot repeat frame after frame at a consistent (delay, Doppler). Three
# claims of the same hex by the same node, with no gap larger than
# CAL_CLAIM_STREAK_GAP_S between consecutive ones, is a LINK rather than a
# coincidence. 10 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 — the same reasoning that puts KNOWN_HOLD_MAX_GAP_S at 8 s,
# loosened because breaking a streak only costs a sample, not a track.
CAL_CLAIM_MIN_CLAIMS = 3
CAL_CLAIM_STREAK_GAP_S = 10.0

# ── ADS-B seeding (ADSB_SEED_MODE) ────────────────────────────────────────────
# A track view exports its ADS-B tag only if one of the newest N history
# detections carries it. A swapped track's newest detections go untagged
Expand Down
20 changes: 20 additions & 0 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,20 @@ def _adsb_for_seeding() -> dict[str, dict]:
# what stops a node that newly acquires a silent aircraft from feeding the
# dark pool and minting a twin key beside the lane's entry.
known_follow_claims: int = 0
# Empirical-coverage calibration from the CLAIM lane (see
# services/known_claiming._calibration_from_claim and services/calibration.py's
# fourth rule). recorded counts the points actually written; the five rejects
# are the five rules, charged in order, so exactly one of the six is bumped per
# non-hold claim and they sum to the claim count. Read them as a funnel: a
# recorded count of zero beside a large `immature` is a fleet whose links are
# too short-lived, and one beside a large `contested` is traffic too dense for
# an exclusive attribution — two different problems with the same symptom.
calibration_points_recorded: int = 0
calibration_claims_rejected_hold: int = 0
calibration_claims_rejected_stale_fix: int = 0
calibration_claims_rejected_residual: int = 0
calibration_claims_rejected_contested: int = 0
calibration_claims_rejected_immature: 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
Expand Down Expand Up @@ -1086,6 +1100,9 @@ def _reset_for_tests() -> None:
global known_claims_errors, known_claims_visibility_rejects, known_claims_world_rejects
global known_hold_claims, known_hold_expired, known_hold_dropped_disagree
global known_follow_claims
global calibration_points_recorded, calibration_claims_rejected_hold
global calibration_claims_rejected_stale_fix, calibration_claims_rejected_residual
global calibration_claims_rejected_contested, calibration_claims_rejected_immature
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
Expand Down Expand Up @@ -1193,6 +1210,9 @@ def _reset_for_tests() -> None:
known_claims_world_rejects = 0
known_hold_claims = known_hold_expired = known_hold_dropped_disagree = 0
known_follow_claims = 0
calibration_points_recorded = calibration_claims_rejected_hold = 0
calibration_claims_rejected_stale_fix = calibration_claims_rejected_residual = 0
calibration_claims_rejected_contested = calibration_claims_rejected_immature = 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
Expand Down
12 changes: 12 additions & 0 deletions backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,11 @@ def _solver_window_stats(minutes: float) -> dict:
kh_expired = state.known_hold_expired
kh_disagree = state.known_hold_dropped_disagree
kf_claims = state.known_follow_claims
cal_recorded = state.calibration_points_recorded
cal_rejects = {
reason: getattr(state, f"calibration_claims_rejected_{reason}")
for reason in ("hold", "stale_fix", "residual", "contested", "immature")
}
# 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
Expand Down Expand Up @@ -1582,6 +1587,13 @@ def _solver_window_stats(minutes: float) -> dict:
# detections that would otherwise have started a dark twin.
"follow_claims": kf_claims,
"holds": sum(len(h) for h in list(state.known_track_holds.values())),
# Empirical-coverage calibration, which under KNOWN_LANE_MODE != off
# comes only from this lane (services/calibration.py's fourth rule).
# recorded is points written; rejected is the five rules, charged in
# order — exactly one per non-hold claim, so they sum with recorded
# to the non-hold claim count.
"calibration_recorded": cal_recorded,
"calibration_rejected": cal_rejects,
},
# Dark published solves against the node pool their round had for the
# same aircraft (see the pooled/shortfalls block above). pct is null
Expand Down
Loading
Loading