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
26 changes: 17 additions & 9 deletions backend/routes/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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
from services.public_geometry import without_receiver_geometry
from services.public_location import fuzz_enabled, public_latlon, translate_polygon
from services.tasks import solver as solver_mod

Expand Down Expand Up @@ -731,11 +732,18 @@ def _mlat_verification_summary() -> dict:

@router.get("/api/test/node/{node_id}/verification")
async def node_verification(node_id: str):
"""Return pre-computed solver-vs-ADS-B verification stats for one node."""
return Response(
content=state.latest_node_verification_bytes.get(node_id, b"{}"),
media_type="application/json",
)
"""Return pre-computed solver-vs-ADS-B verification stats for one node.

Unauthenticated, and everything in a track entry is measured from this one
node's true receiver, so the entries are served node-scoped: the per-track
delays and the node's own solve position go, the errors beside them stay.
Stripped at the route rather than in the store, because it is this
route's one-node addressing that makes the solve position
receiver-relative; the store holds the computation's own output.
"""
raw = state.latest_node_verification_bytes.get(node_id, b"{}")
payload = without_receiver_geometry(orjson.loads(raw), node_scoped=True)
return Response(content=orjson.dumps(payload), media_type="application/json")


@router.get("/api/test/mlat-verification")
Expand Down Expand Up @@ -904,7 +912,7 @@ async def mlat_history(
"n_records": len(skips),
"records": skips[:limit],
}
return Response(content=orjson.dumps(payload), media_type="application/json")
return Response(content=orjson.dumps(without_receiver_geometry(payload)), media_type="application/json")

merged = _merged_solve_history()
effective_minutes = _window_effective_minutes(merged, minutes)
Expand All @@ -927,7 +935,7 @@ async def mlat_history(
"n_records": len(records),
"records": _cap_per_lane(records, limit),
}
return Response(content=orjson.dumps(payload), media_type="application/json")
return Response(content=orjson.dumps(without_receiver_geometry(payload)), media_type="application/json")

norm = (hex or "").strip().lower()
if not norm:
Expand Down Expand Up @@ -966,7 +974,7 @@ async def mlat_history(
"records": rejects_nearby[:200],
},
}
return Response(content=orjson.dumps(payload), media_type="application/json")
return Response(content=orjson.dumps(without_receiver_geometry(payload)), media_type="application/json")


# ── Solver Report (full funnel/error/ghost/consensus picture) ─────────────────
Expand Down Expand Up @@ -1722,7 +1730,7 @@ async def node_detection_range(node_id: str):
status_code=404,
)

summary = {k: v for k, v in area.summary().items() if k != "furthest_detections"}
summary = without_receiver_geometry(area.summary())
rx = summary.get("rx") or {}
pub_lat, pub_lon = public_latlon(rx.get("lat"), rx.get("lon"), node_id)
summary["rx"] = {**rx, "lat": pub_lat, "lon": pub_lon}
Expand Down
99 changes: 99 additions & 0 deletions backend/services/public_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Receiver-relative measurements withheld from unauthenticated payloads.

Every published receiver coordinate is displaced by services/public_location.py.
A quantity measured from a node's TRUE receiver to a point the same payload
gives the position of hands that displacement straight back, and enough of
them intersect well inside it. The line, applied field by field below: a
per-node constant is the envelope and may be published; a per-record value
that varies with the true geometry is a measurement and is withheld. So a beam
entry keeps `max_range_km`, `max_bistatic_range_km` and `half_width_deg`,
which /api/radar/analytics already carries per node, and `rule`, which names
which of them a refused solve breached and so refines an exclusion rather than
bounding the receiver.

The pass is structural over containers, so a field named below is withheld at
any nesting depth. It matches on the leaf key alone, though, not on shape or
position: the same quantity published under a name this module has not seen
stays published until that name is added.
"""

from __future__ import annotations

from typing import Any

# Withheld wherever they appear.
_RECEIVER_RELATIVE = frozenset(
{
# Beam-gate margins (solve records, beam_failures[]): a range and a
# bearing from the true receiver to an aircraft the same record
# locates. bistatic_km joins them because the transmitter is published
# untranslated, so the differential range fixes the receiver on a
# hyperbola through two known foci.
"range_km",
"bearing_off_deg",
"bistatic_km",
# Learned-FOV read-outs at the true bearing. The curve they are read
# off is itself published, as `empirical_polygon` on
# /api/radar/analytics, so a value off it inverts to the bearing it was
# read at; "closed" is the same channel at three-value resolution.
"fov_limit_km",
"fov_state",
# FOV shadow-mode verdicts. Containment tests at the true bearing and
# range, stamped for the nodes that PASSED as well as the ones that
# failed, so each bounds the receiver to a region where a failure would
# only exclude one.
"fov_verdict",
"today_pass",
# Cluster contamination. Membership is _point_in_beam against the true
# geometry and the record publishes that point beside the verdict, so
# it is another labelled sample of the published beam.
#
# /api/test/solver-stats does not route through this pass, and must
# not: its contamination.contaminated is a windowed count sharing the
# name rather than this field, and the pass would delete it.
"foreign_node_ids",
"contaminated",
# Real aircraft fixes each carrying their distance from the true
# receiver: a ranging circle per detection, and three intersect.
"furthest_detections",
# Per-node verification tracks. measured_delay_us is the bistatic range
# from the true receiver to the truth position beside it, and
# delay_match_us its residual against the range predicted from that
# position, so the predicted one follows from the pair.
"measured_delay_us",
"delay_match_us",
# The angle at the truth position between the published transmitter and
# the true receiver of the worst-geometry contributing node, which
# names the direction from a known point towards a receiver.
"max_bistatic_angle_deg",
}
)

# Withheld additionally from a payload scoped to ONE node, where a track
# position is that node's own single-node solve. The aircraft feed publishes
# those same two fields displaced with the icon (services/track_gates.py), so
# the true frame beside them is the displacement by one subtraction. A
# multinode payload keeps them: no single receiver is behind that position.
_NODE_SCOPED = _RECEIVER_RELATIVE | {"solver_lat", "solver_lon"}


def _stripped(value: Any, fields: frozenset[str]) -> Any:
if isinstance(value, dict):
return {k: _stripped(v, fields) for k, v in value.items() if k not in fields}
if isinstance(value, list | tuple):
return [_stripped(v, fields) for v in value]
return value


def without_receiver_geometry(value: Any, *, node_scoped: bool = False) -> Any:
"""`value` with the receiver-relative measurements taken out, at any depth.

`node_scoped` for a payload that answers for one node, where a track
position is that node's own.

Copies rather than editing in place, so the caller's own structure keeps
the geometry: the per-node verification store and the rolling MLAT sample
buffer hold what the route may not publish. Tuples come back as lists,
which is what they would have serialised as anyway.
"""
return _stripped(value, _NODE_SCOPED if node_scoped else _RECEIVER_RELATIVE)
21 changes: 17 additions & 4 deletions backend/services/tasks/analytics_refresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from services.id_utils import multinode_hex_from_key
from services.node_config import position_status
from services.node_sites import log_colocation_audit
from services.public_geometry import without_receiver_geometry
from services.public_location import (
fuzz_enabled,
location_uncertainty_km,
Expand Down Expand Up @@ -1280,6 +1281,19 @@ def _refresh_mlat_accuracy_stats() -> None:
)


def _publish_mlat_verification(result: dict) -> None:
"""Serialise a verification result onto the store the public route serves.

Both writers go through here so a third cannot reach those bytes without
the receiver-geometry pass. The store's only readers are unauthenticated
(GET /api/test/mlat-verification, and the dashboard summary beside it), so
nothing downstream wants the withheld fields back.
"""
state.latest_mlat_verification_bytes = orjson.dumps(
without_receiver_geometry(result), option=orjson.OPT_SERIALIZE_NUMPY
)


def _refresh_mlat_verification():
"""Compare multinode solve results to ground-truth trails pushed by the fleet orchestrator.

Expand Down Expand Up @@ -1406,7 +1420,7 @@ def _refresh_mlat_verification():
# /api/test/mlat-accuracy silently serves numbers frozen at the moment
# the truth feed stopped, with nothing marking them stale.
_refresh_mlat_accuracy_stats()
state.latest_mlat_verification_bytes = orjson.dumps(
_publish_mlat_verification(
{
"computed_at": round(now, 1),
"skip_reason": "no_truth_candidates",
Expand All @@ -1428,8 +1442,7 @@ def _refresh_mlat_verification():
"nearest_truth": {"mean_km": None, "median_km": None, "p95_km": None},
"tracks": [],
},
},
option=orjson.OPT_SERIALIZE_NUMPY,
}
)
return

Expand Down Expand Up @@ -1752,7 +1765,7 @@ def _min_truth_dist_km(kv: tuple) -> float:
"tracks": sorted(unmatched, key=lambda x: x.get("nearest_truth_km") or 999)[:50],
},
}
state.latest_mlat_verification_bytes = orjson.dumps(result, option=orjson.OPT_SERIALIZE_NUMPY)
_publish_mlat_verification(result)


def _ensure_custody_data():
Expand Down
Loading
Loading