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
6 changes: 5 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,10 @@ POLLED_RADAR_DENY_CIDRS=

# Known lane (services/known_claiming.py, services/tasks/known_lane.py).
# KNOWN_PUBLISH_MAX_RMS_DELAY_US: the residual gate on its publishes, like
# SOLVER_RMS_DELAY_MAX_US. KNOWN_LANE_REANCHOR_STREAK: consecutive
# SOLVER_RMS_DELAY_MAX_US. KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US: the geometry
# gate on them, a ceiling on the km of position error each microsecond of delay
# error becomes where the aircraft is (services/solve_geometry.py); 0 disables.
# KNOWN_LANE_REANCHOR_STREAK: consecutive
# self-consistent ghosts before the prior is moved rather than the solve refused;
# 0 disables. KNOWN_FOLLOW_*: how old, and how many solves behind it, the lane's
# own published entry may be to serve as a claiming candidate; a max age of 0
Expand All @@ -605,6 +608,7 @@ POLLED_RADAR_DENY_CIDRS=
# on delay and Doppler, propagating a Doppler rate of at most
# MAX_DOPPLER_RATE_HZ_S from samples no more than RATE_MAX_SPAN_S apart.
# KNOWN_PUBLISH_MAX_RMS_DELAY_US=3.0
# KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US=3.0
# KNOWN_LANE_REANCHOR_STREAK=2
# KNOWN_FOLLOW_MAX_AGE_S=20
# KNOWN_FOLLOW_MIN_SOLVES=3
Expand Down
7 changes: 4 additions & 3 deletions backend/core/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,8 +760,9 @@ def _adsb_for_seeding(rx: tuple[float, float] | None = None) -> dict[str, dict]:
# name finds only the read sites. published counts actual publishes (binding
# only, so it stays zero in shadow), publish_errors the ones that threw, and
# publish_rms_rejected the solves binding would have published but for the
# residual gate, publish_single_site those it withheld because every claim came
# from one receive site. solver_report.py's known_lane block reads them as a funnel.
# residual gate, publish_weak_geometry those it withheld because the claim
# set's delay loci barely cross at the aircraft. solver_report.py's known_lane
# block reads them as a funnel.
known_lane_attempts: int = 0
known_lane_truth_match: int = 0
known_lane_ghost: int = 0
Expand All @@ -770,7 +771,7 @@ def _adsb_for_seeding(rx: tuple[float, float] | None = None) -> dict[str, dict]:
known_lane_published: int = 0
known_lane_publish_errors: int = 0
known_lane_publish_rms_rejected: int = 0
known_lane_publish_single_site: int = 0
known_lane_publish_weak_geometry: 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
Expand Down
24 changes: 0 additions & 24 deletions backend/services/node_sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,30 +427,6 @@ def _near_miss_reason(
return "joined_nearer_site"


def count_sites(positions: dict[str, tuple[float, float] | None]) -> int:
"""How many receive sites these receivers occupy, by this module's two rules.

The same grouping the public geometry uses (exact equality, then the
NODE_FUZZ_SITE_KM proximity join), applied to just the nodes given, so a
caller asking "how many independent receivers does this measurement set
have?" gets the answer the map already publishes: two receivers on one
roof are one site. Pure — it reads no snapshot and touches no database —
so a solver thread can call it per solve. A node with no usable position
is counted as a site of its own: the unknown case must never merge two
receivers into one.
"""
placed: dict[str, tuple[float, float]] = {}
unplaced = 0
for node_id, position in positions.items():
site = _site_of(*position) if position is not None else None
if site is None:
unplaced += 1
else:
placed[node_id] = site
identities, _joined = _cluster(placed, node_fuzz_site_km())
return len(set(identities.values())) + unplaced


def colocation_report() -> dict:
"""Sites being shared, how each member got there, and the pairs still apart.

Expand Down
112 changes: 112 additions & 0 deletions backend/services/solve_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""How well a set of bistatic nodes can place a target — the geometry alone.

A bistatic delay puts the target on an ellipsoid with foci at the node's
transmitter and receiver. At a pinned altitude that is a curve on the map,
and its normal there is the horizontal part of the gradient of the bistatic
range: the sum of the two unit vectors from transmitter to target and from
receiver to target. A fix is where the nodes' curves cross, and how sharply
they cross is all that separates a position from a smear along one curve.

For two nodes that is one crossing angle. It is NOT a property of the
receivers: two nodes on one roof share the receiver term, so their curves
cross at half the angle their two TRANSMITTERS subtend at the target, exactly
as two nodes sharing a transmitter cross at half the angle their receivers
subtend. Co-sited receivers on well separated towers can be a good pair, and
receivers far apart can be a bad one. On test, 2026-10-02, the one real n=2
pair (receivers co-sited, towers 18 km apart, 981 known-lane solves) was 62%
ghosts below a 2 degree crossing and 4% between 10 and 20 degrees.

A crossing angle does not extend past two nodes, so this module reports what
it is the n=2 case of: the dilution of precision of the delay loci. With
``g_i`` each node's horizontal gradient,

DOP = sqrt(trace((sum_i g_i g_i^T)^-1))

is the horizontal position error per unit of independent bistatic-range error
on each node, at any node count and whatever the nodes share. Three receivers
on one roof listening to one tower farm are as degenerate as two; three on one
roof with towers around the compass are a good fix; the number says which. At
n=2 with gradients of magnitude g1, g2 crossing at angle theta it reduces to
``sqrt(g1^2 + g2^2) / (g1 * g2 * sin(theta))``.

Reported in km of position per microsecond of delay, the unit the delay
residual gates beside it already speak. The same history bins by it without
regard to n: at 0.5-1 km/us the fleet's n=2, n=3 and n>=4 solves had median
errors of 0.10, 0.13 and 0.22 km.
"""

import math
from collections.abc import Iterable

from config.constants import C_KM_US, FT_TO_M

_KM_PER_DEG = math.pi * 6371.0 / 180.0

# det(G) below this fraction of trace(G)^2 is a set of parallel loci: for two
# unit gradients the ratio is sin^2(theta)/4, so this is a crossing of about
# 4e-3 degrees, far below anything a threshold on the result could care about.
_SINGULAR_RATIO = 1e-9


def _unit_to_target(east_km: float, north_km: float, up_km: float) -> tuple[float, float] | None:
"""Horizontal part of the unit vector from a focus to the target, the
focus given by its offset FROM the target. None when they coincide."""
dist = math.sqrt(east_km * east_km + north_km * north_km + up_km * up_km)
if dist < 1e-9:
return None
return (-east_km / dist, -north_km / dist)


def _gradient(lat: float, lon: float, alt_km: float, cfg: dict) -> tuple[float, float] | None:
"""One node's horizontal bistatic-range gradient at the target, or None
when the config cannot place both of its foci."""
cos_lat = math.cos(math.radians(lat))
parts = []
for prefix in ("tx", "rx"):
f_lat, f_lon = cfg.get(f"{prefix}_lat"), cfg.get(f"{prefix}_lon")
if not isinstance(f_lat, (int, float)) or not isinstance(f_lon, (int, float)):
return None
f_alt_ft = cfg.get(f"{prefix}_alt_ft")
f_alt_km = float(f_alt_ft) * FT_TO_M / 1000.0 if isinstance(f_alt_ft, (int, float)) else 0.0
unit = _unit_to_target(
(f_lon - lon) * cos_lat * _KM_PER_DEG,
(f_lat - lat) * _KM_PER_DEG,
f_alt_km - alt_km,
)
if unit is None:
return None
parts.append(unit)
return (parts[0][0] + parts[1][0], parts[0][1] + parts[1][1])


def delay_dop_km_per_us(lat: float, lon: float, alt_km: float, node_cfgs: Iterable[dict | None]) -> float | None:
"""Horizontal position error, in km, per microsecond of independent delay
error on each of these nodes, for a target at this position and altitude.

``math.inf`` when the loci are parallel there (every node shares both
foci, or the target sits where the gradients line up). None when the
answer is not known: fewer than two nodes, or a node whose config is
missing or does not place its transmitter and receiver. Callers must not
read None as bad geometry.

Local tangent plane around the target. The curvature it ignores bends a
100 km line of sight by under half a degree, against thresholds that sit
at several degrees of crossing.
"""
a = b = c = 0.0
n = 0
for cfg in node_cfgs:
grad = _gradient(lat, lon, alt_km, cfg) if isinstance(cfg, dict) else None
if grad is None:
return None
a += grad[0] * grad[0]
b += grad[0] * grad[1]
c += grad[1] * grad[1]
n += 1
if n < 2:
return None
trace = a + c
det = a * c - b * b
if det <= _SINGULAR_RATIO * trace * trace:
return math.inf
return math.sqrt(trace / det) * C_KM_US
14 changes: 7 additions & 7 deletions backend/services/solver_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ def _counter_snapshot() -> SimpleNamespace:
known_lane_publish_errors=state.known_lane_publish_errors,
known_lane_reanchored=state.known_lane_reanchored,
known_lane_publish_rms_rejected=state.known_lane_publish_rms_rejected,
known_lane_publish_single_site=state.known_lane_publish_single_site,
known_lane_publish_weak_geometry=state.known_lane_publish_weak_geometry,
known_claims_made=state.known_claims_made,
known_claim_contentions=state.known_claim_contentions,
known_claims_bound=state.known_claims_bound,
Expand Down Expand Up @@ -651,12 +651,12 @@ def _known_lane(snap: SimpleNamespace, known_records: list[dict], minutes: float
# sampled and still in position_error_km below: the gate protects
# the map, not the measurement.
"publish_rms_rejected": snap.known_lane_publish_rms_rejected,
# Solves binding WOULD have published, held off the map because every
# claim came from one receive site (see known_lane._count_sites):
# co-sited receivers are one site's geometry, not a multilateration
# fix. Same accounting as publish_rms_rejected, and charged instead
# of it when both apply.
"publish_single_site": snap.known_lane_publish_single_site,
# Solves binding WOULD have published, held off the map because their
# delay loci barely cross at the aircraft (see known_lane._delay_dop
# and KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US): near-parallel loci are
# one curve's worth of position, not a fix. Same accounting as
# publish_rms_rejected, and charged instead of it when both apply.
"publish_weak_geometry": snap.known_lane_publish_weak_geometry,
# The one WINDOWED entry in this since-boot block (it carries its
# own window_minutes so it cannot be misread as cumulative):
# solver-vs-ADS-B error over this lane's records in the window,
Expand Down
Loading
Loading