Skip to content
Merged
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
27 changes: 26 additions & 1 deletion backend/config/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

Import from here instead of scattering magic numbers through services.
Values that are tunable per-deployment stay as env vars (FRAME_WORKERS,
SOLVER_WORKERS, etc.) — this file is for compile-time constants only.
SOLVER_WORKERS, etc.) — this file holds compile-time constants and the small
pure helpers that operate on them.

retina_tracker YAML config stays separate (loaded at runtime via config.yaml).
"""

import math
import os

from core.env_parsing import parse_comma_list
Expand All @@ -16,6 +18,29 @@
R_EARTH_KM = 6371.0 # Mean Earth radius (km)
FT_TO_M = 0.3048 # Feet → metres

# ── Field coercion ───────────────────────────────────────────────────────────


def is_num(v) -> bool:
"""True when v is a finite number and arithmetic on it is meaningful.

The complement of as_num()'s fallback: a field this rejects carries no
measurement, so a truth comparison must drop the sample rather than score
against the substituted 0.0.
"""
return isinstance(v, (int, float)) and math.isfinite(v)


def as_num(v) -> float:
"""0.0 for anything that isn't a finite number (ADS-B's "ground", None, NaN).

tar1090 reports alt_baro as the literal string "ground" for aircraft on the
ground. Coerce before any arithmetic on a raw ADS-B field. Sound for a
solver seed; use is_num() where the value is compared against truth.
"""
return float(v) if is_num(v) else 0.0


# ── Association gates ────────────────────────────────────────────────────────
DELAY_MATCH_THRESHOLD_US = 15.0 # Bistatic delay tolerance for matching
ASSOC_GRID_STEP_KM = 3.0 # Overlap zone grid resolution (km)
Expand Down
12 changes: 4 additions & 8 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
N2_CONFIRM_MIN_EPOCHS,
N2_CONFIRM_MIN_SPAN_S,
TRACK_HISTORY_MAX, # noqa: F401 — re-exported, used via state.TRACK_HISTORY_MAX
as_num,
)

# ── Coverage / analytics persistence ──────────────────────────────────────────
Expand Down Expand Up @@ -148,11 +149,6 @@ def _global_tracks_for_claiming():
return out


def _as_num(v) -> float:
"""0.0 for anything that isn't a finite number ("ground", None, NaN)."""
return float(v) if isinstance(v, (int, float)) and math.isfinite(v) else 0.0


def _adsb_for_seeding() -> dict[str, dict]:
"""Unlocked snapshot of currently-live ADS-B fixes, in the seeding
provider contract InterNodeAssociator documents on adsb_provider.
Expand All @@ -172,13 +168,13 @@ def _adsb_for_seeding() -> dict[str, dict]:
# the literal string "ground" for on-ground aircraft — so coerce
# before arithmetic; one such record would otherwise throw here on
# every frame for as long as it stays live.
gs_ms = _as_num(rec.get("gs")) * 0.514444
trk = math.radians(_as_num(rec.get("track")))
gs_ms = as_num(rec.get("gs")) * 0.514444
trk = math.radians(as_num(rec.get("track")))
out[hexn] = {
"hex": hexn,
"lat": lat,
"lon": lon,
"alt_m": _as_num(rec.get("alt_baro")) * FT_TO_M,
"alt_m": as_num(rec.get("alt_baro")) * FT_TO_M,
"vel_east": gs_ms * math.sin(trk),
"vel_north": gs_ms * math.cos(trk),
"timestamp_ms": rec.get("last_seen_ms", 0),
Expand Down
50 changes: 36 additions & 14 deletions backend/pipeline/passive_radar.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
FT_TO_M,
GEO_INTERVAL_S,
PRUNE_INTERVAL_S,
as_num,
)
from services.id_utils import passive_track_hex

Expand Down Expand Up @@ -346,6 +347,22 @@ def _init_geolocator(self, config):
self.geo_config.velocity_bounds = list(_DRONE_VELOCITY_BOUNDS)
self.geo_config.initial_altitude_m = _DRONE_INITIAL_ALT_M

@staticmethod
def _coerced_adsb(tag):
"""Numeric fields of an inline ADS-B tag, safe for the geolocator.

Everything the geolocator reads off a detection it multiplies bare and
outside the solver's try, so a raw "ground" altitude or a null gs raises
from inside the library and costs the whole frame.
"""
# A node can put anything in frame["adsb"][i]; nothing validates inside
# a frame, and the geolocator tolerates a non-dict where unpacking does not.
if not isinstance(tag, dict):
return tag
# Never add a key: the geolocator branches on `"gs" in adsb`.
numeric = ("alt_baro", "gs", "track", "geom_rate")
return {k: as_num(v) if k in numeric else v for k, v in tag.items()}

def _geolocate_track_event(self, track_id, event):
"""Run LM solver on a track event to get lat/lon/alt/velocity."""
# Resolve lazy detection reference if needed (only for tracks that
Expand All @@ -369,7 +386,7 @@ def _geolocate_track_event(self, track_id, event):
delay=d["delay"],
doppler=d["doppler"],
snr=d.get("snr", 0),
adsb=d.get("adsb"),
adsb=self._coerced_adsb(d.get("adsb")),
)
)

Expand All @@ -392,12 +409,18 @@ def _geolocate_track_event(self, track_id, event):
# select_initial_guess() always starts from fresh coordinates.
geo_track.adsb_initialized = True
if geo_track.detections:
# Detections are oldest-first: this fix lands on the
# window's oldest epoch, so the guess carries its lag.
geo_track.detections[0].adsb = {
"lat": _adsb["lat"],
"lon": _adsb["lon"],
"alt_baro": _adsb.get("alt_baro", 0),
"gs": _adsb.get("gs", 0),
"track": _adsb.get("track", 0),
# Coerced here, not downstream: the geolocator
# multiplies all three raw, outside the solver's
# try, so a "ground" altitude or a null gs kills
# the frame from inside the library.
"alt_baro": as_num(_adsb.get("alt_baro")),
"gs": as_num(_adsb.get("gs")),
"track": as_num(_adsb.get("track")),
}

# Generate initial guess
Expand Down Expand Up @@ -482,12 +505,11 @@ def _geolocate_track_event(self, track_id, event):
n_detections=len(geo_detections),
timestamp_ms=event["timestamp"],
adsb_hex=event.get("adsb_hex"),
# get_recent_detections() is newest-first, so the freshest
# measurement is [0] — [-1] was the OLDEST in the window, which
# published an ambiguity arc up to a full track-history behind
# the target.
latest_delay_us=geo_detections[0].delay if geo_detections else None,
latest_doppler_hz=geo_detections[0].doppler if geo_detections else None,
# get_recent_detections() returns oldest-first, so the freshest
# measurement is [-1]. This builds the ambiguity arc, which is the
# displayed position for single-node tracks.
latest_delay_us=geo_detections[-1].delay if geo_detections else None,
latest_doppler_hz=geo_detections[-1].doppler if geo_detections else None,
target_class=target_class,
is_anomalous=is_anomalous,
anomaly_types=anomaly_types,
Expand Down Expand Up @@ -537,12 +559,12 @@ def _run_geolocation(self):
existing = self.geolocated_tracks.get(track_id)

# Newest measurement carried by this event (detections are stored
# newest-first). Used to keep the published delay fresh below and
# oldest-first). Used to keep the published delay fresh below and
# to seed the ADS-B bootstrap path so a first-encounter entry
# never publishes delay_us=0.
_dets = event.get("detections")
if _dets:
_newest = _dets[0] # newest-first
_newest = _dets[-1]
else:
_ref = event.get("_track_ref")
_recent = _ref.get_recent_detections(n=1) if _ref is not None else []
Expand Down Expand Up @@ -583,7 +605,7 @@ def _run_geolocation(self):
if adsb:
_gs_ms = (adsb.get("gs", 0) or 0) * 0.514444
_trk = math.radians(adsb.get("track", 0) or 0)
existing.alt_m = (adsb.get("alt_baro", 0) or 0) * FT_TO_M
existing.alt_m = as_num(adsb.get("alt_baro")) * FT_TO_M
existing.vel_east = _gs_ms * math.sin(_trk)
existing.vel_north = _gs_ms * math.cos(_trk)
existing.last_update_ms = event["timestamp"]
Expand Down Expand Up @@ -638,7 +660,7 @@ def _run_geolocation(self):
track_id=track_id,
lat=adsb["lat"],
lon=adsb["lon"],
alt_m=(adsb.get("alt_baro", 0) or 0) * FT_TO_M,
alt_m=as_num(adsb.get("alt_baro")) * FT_TO_M,
vel_east=_gs_ms * math.sin(_trk),
vel_north=_gs_ms * math.cos(_trk),
vel_up=0.0,
Expand Down
2 changes: 0 additions & 2 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ omit = [
"routes/test.py",
# External API clients — require live network; tested via integration only:
"clients/fcc.py",
# blah2 bridge — requires live blah2 hardware/daemon:
"services/blah2_bridge.py",
# Pure TypedDict definitions — no runtime code to cover:
"core/types.py",
]
Expand Down
34 changes: 23 additions & 11 deletions backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from fastapi import APIRouter, Body, Depends, Header, HTTPException
from fastapi.responses import Response

from config.constants import FT_TO_M, is_num
from core import state
from core.task_registry import get_stale_tasks
from core.users import require_admin
Expand Down Expand Up @@ -302,14 +303,17 @@ async def validate_ground_truth(body: dict = Body(...), _key=Depends(_verify_sim
if best_match:
idx, sa = best_match
matched_server_indices.add(idx)
sa_alt_m = sa.get("alt_baro", 0) * 0.3048 if sa.get("alt_baro") else 0
alt_err_m = abs(gt_alt - sa_alt_m)
# "ground" means on the surface — field elevation, not 0 m MSL — so
# it is no altitude truth. Null here rather than a fabricated error;
# the match still scores position.
_sa_alt = sa.get("alt_baro")
alt_err_m = abs(gt_alt - _sa_alt * FT_TO_M) if is_num(_sa_alt) else None
matches.append(
{
"truth_id": gt.get("id"),
"server_hex": sa.get("hex"),
"position_error_km": round(best_dist, 2),
"altitude_error_m": round(alt_err_m, 0),
"altitude_error_m": round(alt_err_m, 0) if alt_err_m is not None else None,
"position_source": sa.get("position_source", "unknown"),
"has_adsb": gt.get("has_adsb", False),
"is_anomalous": gt.get("is_anomalous", False),
Expand All @@ -322,21 +326,28 @@ async def validate_ground_truth(body: dict = Body(...), _key=Depends(_verify_sim

if matches:
pos_errors = [m["position_error_km"] for m in matches]
alt_errors = [m["altitude_error_m"] for m in matches]
avg_pos_err = sum(pos_errors) / len(pos_errors)
avg_alt_err = sum(alt_errors) / len(alt_errors)
max_pos_err = max(pos_errors)
accuracy_pct = len(matches) / len(truth_list) * 100
sorted_pos = sorted(pos_errors)
p50_pos = sorted_pos[len(sorted_pos) // 2]
p95_pos = sorted_pos[int(len(sorted_pos) * 0.95)]
else:
avg_pos_err = max_pos_err = 0
p50_pos = p95_pos = 0
accuracy_pct = 0

# Denominator is the matches that HAD altitude truth, not all of them, so
# these stand apart from the position ones. Null rather than 0 when none
# did: 0 m of altitude error reads as perfect accuracy.
alt_errors = [m["altitude_error_m"] for m in matches if m["altitude_error_m"] is not None]
if alt_errors:
avg_alt_err = sum(alt_errors) / len(alt_errors)
sorted_alt = sorted(alt_errors)
p50_alt = sorted_alt[len(sorted_alt) // 2]
p95_alt = sorted_alt[int(len(sorted_alt) * 0.95)]
else:
avg_pos_err = avg_alt_err = max_pos_err = 0
p50_pos = p95_pos = p50_alt = p95_alt = 0
accuracy_pct = 0
avg_alt_err = p50_alt = p95_alt = None

# Per-position_source breakdown
by_source: dict[str, list[float]] = {}
Expand Down Expand Up @@ -368,9 +379,10 @@ async def validate_ground_truth(body: dict = Body(...), _key=Depends(_verify_sim
"median_position_error_km": round(p50_pos, 2),
"p95_position_error_km": round(p95_pos, 2),
"max_position_error_km": round(max_pos_err, 2),
"avg_altitude_error_m": round(avg_alt_err, 0),
"median_altitude_error_m": round(p50_alt, 0),
"p95_altitude_error_m": round(p95_alt, 0),
"n_altitude_samples": len(alt_errors),
"avg_altitude_error_m": round(avg_alt_err, 0) if avg_alt_err is not None else None,
"median_altitude_error_m": round(p50_alt, 0) if p50_alt is not None else None,
"p95_altitude_error_m": round(p95_alt, 0) if p95_alt is not None else None,
},
"by_source": source_breakdown,
"matches": matches[:50],
Expand Down
6 changes: 5 additions & 1 deletion backend/services/blah2_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ async def blah2_bridge_task(node: Blah2Node):
frame = _convert_frame(raw, node.node_id)
if frame is not None:
ts_ms = raw.get("timestamp", 0)
if ts_ms != last_ts: # skip duplicate frames
# Forward progress only: a repeat or a backwards clock step
# would hand the tracker a non-positive dt. _convert_frame's
# staleness gate must stay above this one - it bounds a
# regression stall to 2 * STALE_THRESHOLD_S rather than forever.
if ts_ms > last_ts:
last_ts = ts_ms
# Update heartbeat timestamp
if node.node_id in state.connected_nodes:
Expand Down
15 changes: 9 additions & 6 deletions backend/services/known_claiming.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
from retina_analytics.constants import offset_latlon_m
from scipy.optimize import linear_sum_assignment

from config.constants import FT_TO_M
from config.constants import FT_TO_M, as_num
from core import state
from services.id_utils import normalize_hex_key

Expand Down Expand Up @@ -113,8 +113,11 @@ def _gate_scale(age_s: float) -> float:


def _tag_velocity(tag: dict) -> tuple[float, float]:
"""(vel_east, vel_north) m/s from a node adsb entry's gs (kt) / track (deg),
the same conversion state._adsb_for_seeding applies to the cache."""
"""(vel_east, vel_north) m/s from a node adsb entry's gs (kt) / track (deg).

Unlike the cache path, these are not coerced: no non-numeric gs or track has
been observed from a node. 86cb9t7c4 tracks closing that gap.
"""
gs_ms = (tag.get("gs", 0) or 0) * 0.514444
trk = math.radians(tag.get("track", 0) or 0)
return gs_ms * math.sin(trk), gs_ms * math.cos(trk)
Expand Down Expand Up @@ -219,9 +222,9 @@ def claim_known_targets(node_id: str, frame: dict) -> set[int]:
ve, vn = _tag_velocity(tag)
# The node correlated this fix against this frame, so the fix is
# taken as current — no dead-reckoning, fix_ts_ms = frame time.
pred_d, pred_f = predict_observation(
geo, lat, lon, (tag.get("alt_baro", 0) or 0) * FT_TO_M / 1000.0, ve, vn
)
# Raw node input: alt_baro is the string "ground" on the deck.
alt_km = as_num(tag.get("alt_baro")) * FT_TO_M / 1000.0
pred_d, pred_f = predict_observation(geo, lat, lon, alt_km, ve, vn)
fix = {
"lat": lat,
"lon": lon,
Expand Down
26 changes: 16 additions & 10 deletions backend/services/tasks/analytics_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import numpy as np
import orjson

from config.constants import ANALYTICS_REFRESH_INTERVAL_S, CLAIMED_DISPLAY_FRESH_S
from config.constants import ANALYTICS_REFRESH_INTERVAL_S, CLAIMED_DISPLAY_FRESH_S, as_num, is_num
from config.constants import (
DELAY_MATCH_THRESHOLD_US as _DELAY_MATCH_THRESHOLD_US,
)
Expand Down Expand Up @@ -925,7 +925,16 @@ def _refresh_node_verification(node_id: str):
_alt_m = best_adsb.get("alt_m")
_gs_kt = best_adsb.get("gs")
_vel_ms = best_adsb.get("velocity")
truth_alt_m = float(_alt_ft) * 0.3048 if _alt_ft is not None else float(_alt_m) if _alt_m is not None else None
# A non-numeric alt_baro is tar1090's "ground" sentinel: on the surface,
# which is field elevation, not 0 m MSL. It is no altitude truth, so the
# candidate falls through to the metric key and, failing that, out of the
# altitude stats — as an absent speed field does out of the velocity ones.
if is_num(_alt_ft):
truth_alt_m = _alt_ft * 0.3048
elif _alt_m is not None:
truth_alt_m = float(_alt_m)
else:
truth_alt_m = None
truth_gs_ms = (
float(_gs_kt) * 0.514444 if _gs_kt is not None else float(_vel_ms) if _vel_ms is not None else None
)
Expand Down Expand Up @@ -1241,13 +1250,10 @@ def _refresh_mlat_verification():
age_s = now - entry.get("last_seen_ms", 0) / 1000
if age_s > 60:
continue
# tar1090 convention: alt_baro is the string "ground" for aircraft on
# the ground — a bare multiply raised TypeError and killed the whole
# refresh cycle, leaving /api/test/mlat-accuracy permanently empty.
_gs_raw = entry.get("gs", 0) or 0
_alt_raw = entry.get("alt_baro", 0) or 0
gs_ms = (_gs_raw if isinstance(_gs_raw, (int, float)) else 0.0) * 0.514444
alt_m = (_alt_raw if isinstance(_alt_raw, (int, float)) else 0.0) * 0.3048
# Raw feed values: tar1090 sends alt_baro as the string "ground" on the
# deck, and json.loads parses a bare NaN, which an isinstance test admits.
gs_ms = as_num(entry.get("gs")) * 0.514444
alt_m = as_num(entry.get("alt_baro")) * 0.3048
adsb_truth_pool.append(
(
adsb_hex,
Expand All @@ -1273,7 +1279,7 @@ def _refresh_mlat_verification():
# heading} (periodic.py) — NOT the tar1090 gs/alt_baro schema.
# Reading gs/alt_baro here zeroed every external truth entry.
gs_ms = float(entry["velocity"] if entry.get("velocity") is not None else (entry.get("gs") or 0) * 0.514444)
alt_m = float(entry["alt_m"] if entry.get("alt_m") is not None else (entry.get("alt_baro") or 0) * 0.3048)
alt_m = float(entry["alt_m"] if entry.get("alt_m") is not None else as_num(entry.get("alt_baro")) * 0.3048)
adsb_truth_pool.append(
(
adsb_hex,
Expand Down
Loading
Loading