diff --git a/backend/.env.example b/backend/.env.example index 7344ee37..587e3cf0 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 @@ -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 diff --git a/backend/core/state.py b/backend/core/state.py index 200118c0..875f3fe0 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -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 @@ -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 diff --git a/backend/services/node_sites.py b/backend/services/node_sites.py index d4cb6049..d049570a 100644 --- a/backend/services/node_sites.py +++ b/backend/services/node_sites.py @@ -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. diff --git a/backend/services/solve_geometry.py b/backend/services/solve_geometry.py new file mode 100644 index 00000000..6ea1e346 --- /dev/null +++ b/backend/services/solve_geometry.py @@ -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 diff --git a/backend/services/solver_report.py b/backend/services/solver_report.py index 6e27e17b..7ea76ea6 100644 --- a/backend/services/solver_report.py +++ b/backend/services/solver_report.py @@ -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, @@ -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, diff --git a/backend/services/tasks/known_lane.py b/backend/services/tasks/known_lane.py index 825ef692..416030cf 100644 --- a/backend/services/tasks/known_lane.py +++ b/backend/services/tasks/known_lane.py @@ -41,14 +41,14 @@ are, superseding the regular pipeline's output for that hex. Ghosts are recorded but never published: a displaced solve under a real hex is a wrong map marker, the same reason the regular - displacement gate exists. Nor is a solve whose claims all come - from ONE receive site (see _count_sites): that is not a - multilateration fix at any node count, and the aircraft is left - to the ADS-B display path. Both publish gates protect the MAP - only — the classification, the accuracy sample and the history - outcome are identical whether they pass or not (bar re-anchoring, - which a single-site solve never earns), so the free-solve - measurement below stays free. + displacement gate exists. Nor is a solve whose delay loci cross + too shallowly at the aircraft to place it (see _delay_dop): + that is one curve's worth of position at any node count, and the + aircraft is left to the ADS-B display path. Both publish gates + protect the MAP only — the classification, the accuracy sample + and the history outcome are identical whether they pass or not + (bar re-anchoring, which a weak-geometry solve never earns), so + the free-solve measurement below stays free. A SECOND PASS lives here too: the dark-follow lane (DARK_FOLLOW_MODE, see services/dark_follow.py) applies the same inversion to aircraft that have no @@ -70,7 +70,7 @@ from config.constants import FT_TO_M from core import state -from services import dark_follow, node_sites, track_filter +from services import dark_follow, solve_geometry, 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 @@ -131,6 +131,35 @@ # solver's so the map can be tightened here without moving the dark lane. KNOWN_PUBLISH_MAX_RMS_DELAY_US = float(os.getenv("KNOWN_PUBLISH_MAX_RMS_DELAY_US", "3.0")) +# ── Publish geometry gate ───────────────────────────────────────────────────── +# Ceiling on the delay dilution of precision at the aircraft (see +# services/solve_geometry.py): km of horizontal error per microsecond of delay +# error. It replaces a rule that counted receive SITES and withheld every +# solve from co-sited receivers, which read the wrong variable: such a set's +# loci cross according to where its TRANSMITTERS are from the aircraft, so the +# same receivers are a good fix for one aircraft and none for the next. On +# test, 2026-10-02, the one real n=2 pair (receivers co-sited, towers 18 km +# apart, 981 solves in 26 min) by this figure: +# +# km/us solves ghosts median error +# under 2 264 3% 0.61 km +# 2 to 3 167 8% 0.68 km +# 3 to 4 96 33% 1.03 km +# 4 to 5 98 38% 1.35 km +# 5 to 8 130 42% 1.65 km +# over 8 226 61% 2.96 km +# +# and the site rule withheld every row alike. The median error over the +# median figure is 0.28 us in each row from 2 to 8, so the error is this +# geometry and nothing else, and 3.0 is where the ghosts start: it keeps 59% +# of the pair's truth_match solves at a 5% ghost rate among the attempts it +# passes. For two typical gradients it is a crossing of about 5 degrees. +# 0 or less disables the gate. +KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US = float(os.getenv("KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US", "3.0")) +# What a history record carries for parallel loci, whose figure is infinite +# and so has no JSON form. +_DELAY_DOP_RECORD_MAX = 9999.0 + # One pass at a time. maybe_run_pass is called from every solver worker # thread's loop; a try-lock (never blocking) means a second worker skips the # pass instead of queueing behind it, and everything below the lock — @@ -389,48 +418,59 @@ def _reanchor(hexn: str, raw_lat: float, raw_lon: float, ts_ms: int) -> bool: return False -def _count_sites(s_in: dict, node_cfgs: dict) -> int: - """How many distinct receive sites the measurements come from. - - Node count overstates what a claim set knows when receivers share a roof. - A bistatic delay pins the target to an ellipsoid with foci at the node's - transmitter and receiver, and its normal bisects the directions to the - two. Two nodes at one receiver share that receiver term, so their loci - cross at HALF the angle their transmitters subtend at the target, where - two sites' loci cross at whatever their full geometry gives — shallow - wherever the transmitters sit on the same side, as they do at the - co-sited pairs on test, and at a pinned altitude the loci may graze or - miss each other entirely. 2026-09-30 on test, a271b0 was claimed by a - 213 MHz and a 201 MHz node whose receivers are 56 m apart: three solves - in a row landed 4.95, 2.32 and 1.90 km from the aircraft, the last at an - rms_delay of 3.59 µs. In the same window the dark lane published eleven - n=2 solves from another co-sited pair, every one 119-169 km from the - nearest aircraft. - - "One site" is the public geometry's rule, not a new one: node_sites' - grouping (exact equality, then NODE_FUZZ_SITE_KM, 150 m by default) over - the positions this solve is actually run against, so two receivers the - map publishes at one point are one site here too. A node absent from node_cfgs, - or with no receiver position, counts as a site of its own — the unknown - case must not demote a solve. +def _delay_dop(s_in: dict, node_cfgs: dict) -> float | None: + """Delay dilution of precision of this claim set at the aircraft, in km of + horizontal error per microsecond of delay error. + + Node count overstates what a claim set knows when its loci run nearly + parallel where the aircraft is, and nothing about the nodes taken apart + from the aircraft says whether they do: receivers on one roof with towers + either side of the target are a good pair, and receivers 30 km apart + looking past the same tower farm are a bad one. services/solve_geometry + has the derivation and why this figure, rather than a crossing angle or a + count of sites, is the one that holds at three nodes and more. + + Evaluated at the initial guess, which in this lane is the transponder's + own position at the solve epoch: the geometry is judged where the aircraft + is, not where a solve that may be the very smear in question put it. + + None when it cannot be known (a node absent from node_cfgs, or one that + does not place its transmitter and receiver) — the unknown case must not + demote a solve. math.inf for parallel loci. """ - positions = {} - for m in s_in.get("measurements") or (): - nid = m.get("node_id") - cfg = node_cfgs.get(nid) if isinstance(node_cfgs, dict) else None - positions[nid] = (cfg.get("rx_lat"), cfg.get("rx_lon")) if isinstance(cfg, dict) else None - return node_sites.count_sites(positions) + ig = s_in.get("initial_guess") or {} + try: + lat, lon, alt_km = float(ig["lat"]), float(ig["lon"]), float(ig.get("alt_km") or 0.0) + except (KeyError, TypeError, ValueError): + return None + cfgs = node_cfgs if isinstance(node_cfgs, dict) else {} + return solve_geometry.delay_dop_km_per_us( + lat, lon, alt_km, [cfgs.get(m.get("node_id")) for m in s_in.get("measurements") or ()] + ) + + +def _geometry_ok(delay_dop: float | None) -> bool: + """Whether the publish geometry gate passes this figure.""" + if delay_dop is None or KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US <= 0: + return True + return delay_dop <= KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US + + +def _delay_dop_field(delay_dop: float | None) -> float | None: + """The figure as a record carries it: rounded, and finite.""" + return None if delay_dop is None else round(min(delay_dop, _DELAY_DOP_RECORD_MAX), 3) -def _record_accuracy(hexn: str, err_km: float, label: str, n_nodes: int, ts_s: float, n_sites: int) -> None: +def _record_accuracy(hexn: str, err_km: float, label: str, n_nodes: int, ts_s: float, delay_dop: float | None) -> None: """Append one known-lane sample to the rolling accuracy store, at most one per hex per _ACCURACY_SAMPLE_INTERVAL_S. Same store and base shape as track_gates._record_accuracy_sample, so _refresh_accuracy_stats bins it by position_source with no changes — known_lane_truth_match vs known_lane_ghost is the headline comparison — - plus label/n_nodes/n_sites as their own fields so later analysis can bin by - GDOP proxy without parsing them back out of the source string. + plus label/n_nodes/delay_dop_km_per_us as their own fields so later + analysis can bin by geometry without parsing them back out of the source + string. Every converged outcome is *classified*, ghost included (a ghost's error is exactly the datum the regular pipeline's displacement gate used to @@ -467,7 +507,7 @@ def _record_accuracy(hexn: str, err_km: float, label: str, n_nodes: int, ts_s: f "position_source": f"known_lane_{label}", "label": label, "n_nodes": n_nodes, - "n_sites": n_sites, + "delay_dop_km_per_us": _delay_dop_field(delay_dop), "ts": round(ts_s, 1), } ) @@ -532,7 +572,8 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non record's displacement_km and the accuracy error are the same number. """ state.bump_counter("known_lane_attempts") - n_sites = _count_sites(s_in, node_cfgs) + delay_dop = _delay_dop(s_in, node_cfgs) + geometry_ok = _geometry_ok(delay_dop) # Same correction, same flag, same helper as the regular lane — see # solver.align_measurement_epochs. Applied here rather than in # _build_solver_input because the alignment needs the node configs, and @@ -564,7 +605,7 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non "label": "no_converge", "published": False, "seed_source": s_in.get("seed_source", "fix"), - "n_sites": n_sites, + "delay_dop_km_per_us": _delay_dop_field(delay_dop), **epoch_meta, }, ) @@ -577,19 +618,20 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non # The prior is only a prior — when it is the lane's OWN dead-reckoned # solve rather than a transponder fix, a repeated self-consistent # disagreement means the prior moved, not the solve. See _reanchor. - # Not for a single-site solve: repeatability is the evidence re-anchoring - # rests on, and a degenerate geometry repeats its wrong answer as reliably - # as a good one repeats the aircraft's position. + # Not for a solve the geometry gate would withhold: repeatability is the + # evidence re-anchoring rests on, and near-parallel loci repeat their + # wrong answer as reliably as a good crossing repeats the aircraft's + # position. if ( label == "ghost" - and n_sites >= 2 + and geometry_ok and s_in.get("seed_source") == "kf" and _reanchor(hexn, raw_lat, raw_lon, int(s_in["timestamp_ms"])) ): label = "reanchored" state.bump_counter(f"known_lane_{label}") n_nodes = int(result.get("n_nodes") or s_in.get("n_nodes") or 0) - _record_accuracy(hexn, err_km, label, n_nodes, s_in["timestamp_ms"] / 1000.0, n_sites) + _record_accuracy(hexn, err_km, label, n_nodes, s_in["timestamp_ms"] / 1000.0, delay_dop) # Residual gate on the PUBLISH only (see KNOWN_PUBLISH_MAX_RMS_DELAY_US), # applied at every n, n=2 included. A missing or None rms_delay passes, @@ -604,21 +646,20 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non # What the gate cannot do is catch an n=2 solve that fits perfectly and # is wrong — the displacement classifier remains the check for that. rms_ok = (result.get("rms_delay") or 0) <= KNOWN_PUBLISH_MAX_RMS_DELAY_US - # Site gate (see _count_sites): measurements from one receive site are not - # a multilateration fix, whatever their node count. Checked first so the - # record names the more fundamental reason when both apply. - sites_ok = n_sites >= 2 + # Geometry gate (see _delay_dop): loci that barely cross at the aircraft + # are not a fix, whatever their node count. Checked first so the record + # names the more fundamental reason when both apply. publishable = mode == "binding" and label in ("truth_match", "reanchored") - published = publishable and sites_ok and rms_ok + published = publishable and geometry_ok and rms_ok # Named in the record because the counter alone cannot say WHICH solve was # withheld, and "published False under a truth_match label in binding" is # otherwise indistinguishable from a publish that threw. None whenever a # gate was not what stopped it — including in shadow, where nothing was # going to publish anyway and a rejection would be a phantom. publish_gate = None - if publishable and not sites_ok: - publish_gate = "single_site" - state.bump_counter("known_lane_publish_single_site") + if publishable and not geometry_ok: + publish_gate = "weak_geometry" + state.bump_counter("known_lane_publish_weak_geometry") elif publishable and not rms_ok: publish_gate = "rms_delay" state.bump_counter("known_lane_publish_rms_rejected") @@ -654,7 +695,7 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non "label": label, "published": published, "publish_gate": publish_gate, - "n_sites": n_sites, + "delay_dop_km_per_us": _delay_dop_field(delay_dop), "seed_source": s_in.get("seed_source", "fix"), **epoch_meta, }, diff --git a/backend/tests/test_known_lane.py b/backend/tests/test_known_lane.py index 596d6e90..7ff085ec 100644 --- a/backend/tests/test_known_lane.py +++ b/backend/tests/test_known_lane.py @@ -16,6 +16,9 @@ - the publish-only residual gate: a truth_match whose rms_delay fails the regular lane's bound is classified and sampled exactly as before, but stays off the map; +- the publish-only geometry gate: a claim set whose delay loci barely cross + at the aircraft is measured and withheld at any node count, and receivers + sharing a roof are not what decides it; - a publish that raises is contained to its own hex — counted, recorded as unpublished, and never allowed to abort the rest of the pass; - claim selection: staleness window, single-node and contested claims produce @@ -70,10 +73,11 @@ "tx_alt_ft": 450, "fc_hz": 100e6, }, - # Two more receivers beside node_a, each with its OWN transmitter — the - # a271b0 shape from test, 2026-09-30: node_a2 is 56 m from node_a, inside - # the 150 m co-location radius (one site); node_a3 is ~200 m away, - # outside it (a second site, however close). + # More receivers on node_a's roof (all within 60 m of it), which differ + # in where their transmitters are. node_a2 and node_a4 each listen to a + # tower of their own, well away from node_a's and from each other as seen + # from TARGET. node_f2 and node_f3 listen to masts on node_a's tower + # farm, within a kilometre of its transmitter. "node_a2": { "rx_lat": 40.7128 + 56.0 / 111_195.0, "rx_lon": -74.0060, @@ -83,19 +87,40 @@ "tx_alt_ft": 480, "fc_hz": 100e6, }, - "node_a3": { - "rx_lat": 40.7128 + 200.0 / 111_195.0, + "node_a4": { + "rx_lat": 40.7128 - 40.0 / 111_195.0, "rx_lon": -74.0060, "rx_alt_ft": 100, - "tx_lat": 40.80, - "tx_lon": -74.10, - "tx_alt_ft": 480, + "tx_lat": 40.64, + "tx_lon": -73.88, + "tx_alt_ft": 520, + "fc_hz": 100e6, + }, + "node_f2": { + "rx_lat": 40.7128 + 56.0 / 111_195.0, + "rx_lon": -74.0060, + "rx_alt_ft": 100, + "tx_lat": 40.78 + 400.0 / 111_195.0, + "tx_lon": -73.95 + 0.0036, + "tx_alt_ft": 500, + "fc_hz": 100e6, + }, + "node_f3": { + "rx_lat": 40.7128 - 50.0 / 111_195.0, + "rx_lon": -74.0060, + "rx_alt_ft": 100, + "tx_lat": 40.78 - 500.0 / 111_195.0, + "tx_lon": -73.95 - 0.0048, + "tx_alt_ft": 500, "fc_hz": 100e6, }, } # Target inside the node triangle: lat, lon, alt_km. TARGET = (40.73, -73.95, 8.0) +# Two hundred kilometres north of every node: from there node_a, node_b and +# node_c, three separate sites, all lie in one direction. +FAR_TARGET = (42.5, -73.95, 8.0) _SENTINEL = object() @@ -555,131 +580,190 @@ def test_n2_residual_is_gated_like_any_other(self): assert rec["publish_gate"] == "rms_delay" -class TestSingleSiteGate: - """Claims from receivers at one site are one site's geometry, not a fix. +class TestGeometryGate: + """A claim set whose delay loci barely cross at the aircraft is not a fix. - Two receivers within the co-location radius (node_sites, NODE_FUZZ_SITE_KM, - 150 m by default) share the receiver focus of every bistatic ellipse they - measure, so their loci cross at half the angle their transmitters subtend — - an n=2 from them is not the n=2 two sites give. a271b0 on test, - 2026-09-30, claimed by two receivers 56 m apart: solves 4.95, 2.32 and - 1.90 km off. Such a solve is still attempted, classified, sampled and - recorded; it is never published, and the aircraft is left to the ADS-B - display path. + Each node's delay puts the aircraft on a curve whose normal is the sum of + the unit vectors from its transmitter and from its receiver. What a set + of nodes knows is how those normals spread, measured as the dilution of + precision in services/solve_geometry.py — and that is not a property of + the receivers. Two on one roof with towers either side of the aircraft + are a good pair; three on one roof listening to one tower farm are no + better than two; three separate sites seen from 200 km are one direction. + A withheld solve is still attempted, classified, sampled and recorded, and + the aircraft is left to the ADS-B display path. """ - def test_cosited_pair_is_measured_but_not_published(self): + def test_cosited_receivers_on_separate_towers_publish(self): + """The case the site-count rule got wrong: one roof, two towers well + apart as seen from the aircraft.""" _install(_mk_claims(["node_a", "node_a2"])) _run(_stub_solve(), mode="binding") + assert state.known_lane_published == 1 + assert state.known_lane_publish_weak_geometry == 0 + (rec,) = _known_records() + assert rec["publish_gate"] is None + assert rec["delay_dop_km_per_us"] < 2.0 + + def test_cosited_receivers_on_one_tower_farm_are_measured_but_not_published(self): + _install(_mk_claims(["node_a", "node_f2"])) + _run(_stub_solve(), mode="binding") + # The measurement is untouched: classified and sampled as before. assert state.known_lane_truth_match == 1 (sample,) = list(state.accuracy_samples) assert sample["position_source"] == "known_lane_truth_match" assert sample["n_nodes"] == 2 - assert sample["n_sites"] == 1 + assert sample["delay_dop_km_per_us"] > known_lane.KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US # ...and off the map. assert state.known_lane_published == 0 - assert state.known_lane_publish_single_site == 1 + assert state.known_lane_publish_weak_geometry == 1 assert state.known_lane_publish_rms_rejected == 0 assert not state.multinode_tracks (rec,) = _known_records() assert rec["label"] == "truth_match" assert rec["published"] is False - assert rec["publish_gate"] == "single_site" + assert rec["publish_gate"] == "weak_geometry" assert rec["n_nodes"] == 2 - assert rec["n_sites"] == 1 + assert rec["delay_dop_km_per_us"] == sample["delay_dop_km_per_us"] - def test_cosited_pair_through_the_real_solver(self): - """End to end on real trigonometry: clean claims from the co-sited - pair still converge (the gate is not about the solver failing), and - the solve still stays off the map.""" - _install(_mk_claims(["node_a", "node_a2"])) + def test_tower_farm_pair_through_the_real_solver(self): + """End to end on real trigonometry: clean claims from the pair still + converge (the gate is not about the solver failing), and the solve + still stays off the map.""" + _install(_mk_claims(["node_a", "node_f2"])) _run(solve_multinode, mode="binding") assert state.known_lane_attempts == 1 assert state.known_lane_no_converge == 0 assert state.known_lane_published == 0 - assert state.known_lane_publish_single_site == 1 + assert state.known_lane_publish_weak_geometry == 1 assert not state.multinode_tracks - def test_single_site_is_named_over_a_failing_residual(self): - """Both gates fail: the record names the more fundamental one, and - exactly one counter is charged, so the two still sum to the withheld - publishes.""" - _install(_mk_claims(["node_a", "node_a2"])) - _run(_stub_solve(rms_delay=3.59), mode="binding") + def test_a_third_receiver_on_the_same_farm_does_not_make_a_fix(self): + """Node count is not geometry: three curves through one point at + nearly one angle place the aircraft no better than two.""" + _install(_mk_claims(["node_a", "node_f2", "node_f3"])) + _run(_stub_solve(), mode="binding") - assert state.known_lane_publish_single_site == 1 - assert state.known_lane_publish_rms_rejected == 0 + assert state.known_lane_published == 0 + assert state.known_lane_publish_weak_geometry == 1 (rec,) = _known_records() - assert rec["publish_gate"] == "single_site" + assert rec["n_nodes"] == 3 + assert rec["publish_gate"] == "weak_geometry" - def test_a_receiver_just_outside_the_radius_is_a_second_site(self): - _install(_mk_claims(["node_a", "node_a3"])) + def test_three_cosited_receivers_on_spread_towers_publish(self): + _install(_mk_claims(["node_a", "node_a2", "node_a4"])) _run(_stub_solve(), mode="binding") assert state.known_lane_published == 1 - assert state.known_lane_publish_single_site == 0 (rec,) = _known_records() - assert rec["n_sites"] == 2 + assert rec["n_nodes"] == 3 assert rec["publish_gate"] is None + assert rec["delay_dop_km_per_us"] < 1.0 - def test_cosited_pair_plus_a_second_site_publishes(self): - """Three nodes at two sites is a multilateration fix: the gate only - removes solves with no second site at all.""" - _install(_mk_claims(["node_a", "node_a2", "node_b"])) + def test_a_farm_pair_plus_a_node_elsewhere_publishes(self): + """The farm pair adds little, and takes nothing away: the set is + judged on everything it holds.""" + _install(_mk_claims(["node_a", "node_f2", "node_b"])) _run(_stub_solve(), mode="binding") assert state.known_lane_published == 1 (rec,) = _known_records() assert rec["n_nodes"] == 3 - assert rec["n_sites"] == 2 assert rec["publish_gate"] is None - def test_the_radius_is_the_colocation_radius(self, monkeypatch): - """Not a constant of this lane's own: NODE_FUZZ_SITE_KM moves it, and - at zero only an exact-coordinate match is one site.""" - monkeypatch.setenv("NODE_FUZZ_SITE_KM", "0") - _install(_mk_claims(["node_a", "node_a2"])) + @pytest.mark.parametrize("node_ids", [["node_a", "node_b"], ["node_a", "node_b", "node_c"]]) + def test_separate_sites_seen_from_one_direction_are_withheld(self, node_ids): + """Receivers apart are not a fix either when the aircraft is far + enough off that every focus lies the same way. The site-count rule + published these.""" + _install(_mk_claims(node_ids, target=FAR_TARGET)) + _run(_stub_solve(), mode="binding") + + assert state.known_lane_published == 0 + assert state.known_lane_publish_weak_geometry == 1 + (rec,) = _known_records() + assert rec["publish_gate"] == "weak_geometry" + + def test_parallel_loci_are_recorded_finite(self, monkeypatch): + """Two nodes sharing both foci measure one curve twice. The figure is + infinite; the record carries the cap, since infinity has no JSON.""" + monkeypatch.setitem(NODE_CFGS, "node_twin", dict(NODE_CFGS["node_a"])) + _install(_mk_claims(["node_a", "node_twin"])) + _run(_stub_solve(), mode="binding") + + assert state.known_lane_published == 0 + (rec,) = _known_records() + assert rec["publish_gate"] == "weak_geometry" + assert rec["delay_dop_km_per_us"] == known_lane._DELAY_DOP_RECORD_MAX + + def test_weak_geometry_is_named_over_a_failing_residual(self): + """Both gates fail: the record names the more fundamental one, and + exactly one counter is charged, so the two still sum to the withheld + publishes.""" + _install(_mk_claims(["node_a", "node_f2"])) + _run(_stub_solve(rms_delay=3.59), mode="binding") + + assert state.known_lane_publish_weak_geometry == 1 + assert state.known_lane_publish_rms_rejected == 0 + (rec,) = _known_records() + assert rec["publish_gate"] == "weak_geometry" + + def test_raising_the_ceiling_publishes_the_same_solve(self, monkeypatch): + """KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US is the whole gate, read at + attempt time.""" + monkeypatch.setattr(known_lane, "KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US", 20.0) + _install(_mk_claims(["node_a", "node_f2"])) + _run(_stub_solve(), mode="binding") + + assert state.known_lane_published == 1 + assert state.known_lane_publish_weak_geometry == 0 + + def test_zero_disables_the_gate(self, monkeypatch): + monkeypatch.setattr(known_lane, "KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US", 0.0) + _install(_mk_claims(["node_a", "node_f2"])) _run(_stub_solve(), mode="binding") assert state.known_lane_published == 1 (rec,) = _known_records() - assert rec["n_sites"] == 2 + assert rec["publish_gate"] is None - def test_a_node_without_a_position_is_its_own_site(self): + def test_a_node_without_a_config_does_not_demote_the_solve(self): """The unknown case must never demote a solve.""" - _install(_mk_claims(["node_a", "node_a2"])) + _install(_mk_claims(["node_a", "node_f2"])) cfgs = {"node_a": NODE_CFGS["node_a"]} known_lane.run_known_lane_pass(_stub_solve(), cfgs, mode="binding") assert state.known_lane_published == 1 (rec,) = _known_records() - assert rec["n_sites"] == 2 + assert rec["delay_dop_km_per_us"] is None def test_shadow_mode_never_counts_a_rejection(self): - _install(_mk_claims(["node_a", "node_a2"])) + _install(_mk_claims(["node_a", "node_f2"])) _run(_stub_solve()) - assert state.known_lane_publish_single_site == 0 + assert state.known_lane_publish_weak_geometry == 0 (rec,) = _known_records() assert rec["publish_gate"] is None - assert rec["n_sites"] == 1 + assert rec["delay_dop_km_per_us"] > known_lane.KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US - def test_no_converge_record_carries_the_site_count(self): - _install(_mk_claims(["node_a", "node_a2"])) + def test_no_converge_record_carries_the_figure(self): + _install(_mk_claims(["node_a", "node_f2"])) _run(_stub_solve(success=False), mode="binding") (rec,) = _known_records() assert rec["outcome"] == "known_no_converge" - assert rec["n_sites"] == 1 + assert rec["delay_dop_km_per_us"] > known_lane.KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US - def test_single_site_ghosts_never_reanchor(self, monkeypatch): + @pytest.mark.parametrize(("node_ids", "reanchored"), [(("node_a", "node_f2"), 0), (("node_a", "node_a2"), 1)]) + def test_only_a_sound_geometry_reanchors(self, monkeypatch, node_ids, reanchored): """Re-anchoring trusts two kf-seeded ghosts that agree with each other. - A degenerate geometry repeats its wrong answer as reliably as a good - one repeats the truth, so agreement from one site is no evidence.""" + Near-parallel loci repeat their wrong answer as reliably as a good + crossing repeats the truth, so their agreement is no evidence — while + the same roof with towers apart earns it like any other pair.""" monkeypatch.setattr(state, "SOLVER_EPOCH_ALIGN", False) t0 = int(time.time() * 1000) for i in range(3): @@ -690,7 +774,7 @@ def test_single_site_ghosts_never_reanchor(self, monkeypatch): "initial_velocity": {"vel_east_ms": 0.0, "vel_north_ms": 0.0}, "measurements": [ {"node_id": nid, "delay_us": 100.0, "doppler_hz": 5.0, "snr": 20.0, "t_s": ts / 1000.0} - for nid in ("node_a", "node_a2") + for nid in node_ids ], "n_nodes": 2, "timestamp_ms": ts, @@ -699,9 +783,10 @@ def test_single_site_ghosts_never_reanchor(self, monkeypatch): } known_lane._attempt(HEX, s_in, NODE_CFGS, _stub_solve(lat_off=0.15), "binding") - assert state.known_lane_reanchored == 0 - assert state.known_lane_ghost == 3 - assert not state.multinode_tracks + assert (state.known_lane_reanchored > 0) == bool(reanchored) + if not reanchored: + assert state.known_lane_ghost == 3 + assert not state.multinode_tracks class TestPublishFailureContainment: diff --git a/backend/tests/test_node_sites.py b/backend/tests/test_node_sites.py index d85334dc..9fec1114 100644 --- a/backend/tests/test_node_sites.py +++ b/backend/tests/test_node_sites.py @@ -510,43 +510,3 @@ def test_km_between_is_the_real_distance(): gap = ns._km_between((_SITE_LAT, _SITE_LON), (_SITE_LAT + 0.01, _SITE_LON)) assert gap == pytest.approx(1.11, abs=0.02) assert not math.isnan(gap) - - -class TestCountSites: - """count_sites: the grouping above, over just the nodes a caller names.""" - - _M = 1.0 / 111_195.0 # degrees of latitude per metre - - def test_receivers_within_the_radius_are_one_site(self): - positions = {"a": (_SITE_LAT, _SITE_LON), "b": (_SITE_LAT + 56 * self._M, _SITE_LON)} - assert ns.count_sites(positions) == 1 - - def test_receivers_beyond_the_radius_are_two(self): - positions = {"a": (_SITE_LAT, _SITE_LON), "b": (_SITE_LAT + 200 * self._M, _SITE_LON)} - assert ns.count_sites(positions) == 2 - - def test_exact_equality_is_one_site(self): - positions = {"a": (_SITE_LAT, _SITE_LON), "b": (_SITE_LAT, _SITE_LON), "c": (_SITE_LAT + 0.1, _SITE_LON)} - assert ns.count_sites(positions) == 2 - - def test_the_radius_follows_the_environment(self, monkeypatch): - monkeypatch.setenv("NODE_FUZZ_SITE_KM", "0") - positions = {"a": (_SITE_LAT, _SITE_LON), "b": (_SITE_LAT + 56 * self._M, _SITE_LON)} - assert ns.count_sites(positions) == 2 - - def test_an_unplaced_node_is_a_site_of_its_own(self): - """Unknown must never merge: a node with no position, or a partial - one, is counted once on its own.""" - positions = {"a": (_SITE_LAT, _SITE_LON), "b": None, "c": (None, _SITE_LON)} - assert ns.count_sites(positions) == 3 - - def test_reads_no_snapshot(self, monkeypatch): - """Pure: the solver thread calls it per solve, so it must not refresh - the module's position snapshot (a database read).""" - - def boom(): - raise AssertionError("count_sites touched the snapshot") - - monkeypatch.setattr(ns, "_refresh_locked", boom) - assert ns.count_sites({"a": (_SITE_LAT, _SITE_LON)}) == 1 - assert ns.count_sites({}) == 0 diff --git a/backend/tests/test_solve_geometry.py b/backend/tests/test_solve_geometry.py new file mode 100644 index 00000000..86f91557 --- /dev/null +++ b/backend/tests/test_solve_geometry.py @@ -0,0 +1,180 @@ +"""Tests for services/solve_geometry.py: the delay dilution of precision. + +The figure is the horizontal position error per microsecond of delay error, +from the spread of the nodes' bistatic-range gradients at the target. Pinned +here: its closed form at two nodes, that it reads the transmitters and not +only the receivers, that it extends to three and four nodes on one roof, and +that an unknown geometry answers None rather than a number. +""" + +import math + +import pytest + +from config.constants import C_KM_US +from services import solve_geometry +from services.solve_geometry import delay_dop_km_per_us + +_KM = 1.0 / 111.195 # degrees of latitude per km +LAT0, LON0 = 40.0, -75.0 + + +def _at(east_km: float, north_km: float) -> tuple[float, float]: + return (LAT0 + north_km * _KM, LON0 + east_km * _KM / math.cos(math.radians(LAT0))) + + +def _node(rx_km, tx_km) -> dict: + rx_lat, rx_lon = _at(*rx_km) + tx_lat, tx_lon = _at(*tx_km) + return {"rx_lat": rx_lat, "rx_lon": rx_lon, "tx_lat": tx_lat, "tx_lon": tx_lon} + + +def _bearing(r_km: float, deg: float) -> tuple[float, float]: + return (r_km * math.sin(math.radians(deg)), r_km * math.cos(math.radians(deg))) + + +# The target is at the origin on the ground, so every gradient is horizontal +# and its direction is the bisector of the bearings to its two foci. +def _dop(nodes) -> float | None: + return delay_dop_km_per_us(LAT0, LON0, 0.0, nodes) + + +class TestTwoNodes: + def test_matches_the_closed_form(self): + """sqrt(g1^2 + g2^2) / (g1 g2 sin(theta)), with the gradients built + by hand from the bearings.""" + rx = _bearing(30.0, 200.0) + a, b = _node(rx, _bearing(40.0, 80.0)), _node(rx, _bearing(40.0, 140.0)) + + def grad(tx_deg): + u = [-math.sin(math.radians(d)) for d in (tx_deg, 200.0)] + v = [-math.cos(math.radians(d)) for d in (tx_deg, 200.0)] + return (sum(u), sum(v)) + + g1, g2 = grad(80.0), grad(140.0) + m1, m2 = math.hypot(*g1), math.hypot(*g2) + sin_theta = abs(g1[0] * g2[1] - g1[1] * g2[0]) / (m1 * m2) + expected = math.sqrt(m1 * m1 + m2 * m2) / (m1 * m2 * sin_theta) * C_KM_US + + assert _dop([a, b]) == pytest.approx(expected, rel=1e-3) + + def test_cosited_receivers_cross_at_half_the_angle_between_their_towers(self): + """Towers 60 degrees apart from the target, one shared receiver: the + normals are the two bisectors, 30 degrees apart.""" + rx = _bearing(30.0, 200.0) + a, b = _node(rx, _bearing(40.0, 80.0)), _node(rx, _bearing(40.0, 140.0)) + ga = solve_geometry._gradient(LAT0, LON0, 0.0, a) + gb = solve_geometry._gradient(LAT0, LON0, 0.0, b) + cross = abs(ga[0] * gb[1] - ga[1] * gb[0]) / (math.hypot(*ga) * math.hypot(*gb)) + + assert math.degrees(math.asin(cross)) == pytest.approx(30.0, abs=0.1) + + def test_a_shared_tower_is_the_mirror_case(self): + """Swap the roles of transmitter and receiver and nothing changes.""" + shared = _bearing(30.0, 200.0) + cosited_rx = [_node(shared, _bearing(40.0, 80.0)), _node(shared, _bearing(40.0, 140.0))] + shared_tx = [_node(_bearing(40.0, 80.0), shared), _node(_bearing(40.0, 140.0), shared)] + + assert _dop(cosited_rx) == pytest.approx(_dop(shared_tx), rel=1e-9) + + def test_it_grows_as_the_towers_close_up(self): + rx = _bearing(30.0, 200.0) + dops = [ + _dop([_node(rx, _bearing(40.0, 110.0 - half)), _node(rx, _bearing(40.0, 110.0 + half))]) + for half in (30.0, 10.0, 3.0, 1.0) + ] + + assert dops == sorted(dops) + assert dops[0] < 1.0 + assert dops[-1] > 10.0 + + def test_cosited_receivers_on_one_mast_are_parallel(self): + tx = _bearing(40.0, 90.0) + a, b = _node(_bearing(30.0, 200.0), tx), _node(_bearing(30.03, 200.0), tx) + + assert _dop([a, b]) > 1000.0 + assert _dop([a, a]) == math.inf + + +class TestMoreThanTwoNodes: + def test_three_on_one_roof_with_towers_around_the_compass(self): + rx = _bearing(30.0, 200.0) + nodes = [_node(rx, _bearing(40.0, d)) for d in (20.0, 140.0, 260.0)] + + assert _dop(nodes) < 0.5 + + def test_three_on_one_roof_and_one_tower_farm(self): + """Masts within a kilometre of each other at 40 km: the third node + halves the variance and leaves the geometry where it was.""" + rx = _bearing(30.0, 200.0) + farm = [_node(rx, _bearing(40.0, 90.0 + d)) for d in (0.0, 0.8, -0.7)] + + assert _dop(farm) > 10.0 + assert _dop(farm) < _dop(farm[:2]) + + def test_four_on_one_roof_and_two_farms(self): + """Two masts on each of two farms: as good as the farms are apart, + and better than one node per farm by the averaging alone.""" + rx = _bearing(30.0, 200.0) + one_each = [_node(rx, _bearing(40.0, 70.0)), _node(rx, _bearing(40.0, 130.0))] + two_each = [_node(rx, _bearing(40.0, d)) for d in (70.0, 70.6, 130.0, 130.6)] + + assert _dop(two_each) == pytest.approx(_dop(one_each) / math.sqrt(2.0), rel=0.05) + + def test_a_node_never_makes_it_worse(self): + rx = _bearing(30.0, 200.0) + pair = [_node(rx, _bearing(40.0, 80.0)), _node(rx, _bearing(40.0, 81.0))] + third = _node(_bearing(25.0, 300.0), _bearing(35.0, 10.0)) + + assert _dop([*pair, third]) < _dop(pair) + + def test_order_does_not_matter(self): + rx = _bearing(30.0, 200.0) + nodes = [_node(rx, _bearing(40.0, d)) for d in (20.0, 95.0, 260.0)] + + assert _dop(nodes) == pytest.approx(_dop(nodes[::-1]), rel=1e-12) + + +class TestAltitude: + def test_a_target_overhead_of_everything_has_no_horizontal_geometry(self): + """Straight above a co-sited pair's shared receiver and far above its + towers the curves flatten: the horizontal gradients shrink and the + figure climbs.""" + rx = (0.0, 0.0) + nodes = [_node(rx, _bearing(5.0, 80.0)), _node(rx, _bearing(5.0, 140.0))] + + assert delay_dop_km_per_us(LAT0, LON0, 12.0, nodes) > delay_dop_km_per_us(LAT0, LON0, 1.0, nodes) + + def test_reads_the_foci_altitudes_in_feet(self): + rx = _bearing(3.0, 200.0) + low = [_node(rx, _bearing(4.0, 80.0)), _node(rx, _bearing(4.0, 140.0))] + high = [dict(n, tx_alt_ft=3280.84, rx_alt_ft=3280.84) for n in low] + + # Foci raised to the target's own 1 km: the same picture as all three + # on the ground. + assert delay_dop_km_per_us(LAT0, LON0, 1.0, high) == pytest.approx(_dop(low), rel=1e-6) + + +class TestUnknown: + """None is "not known", and callers must not read it as bad geometry.""" + + def test_fewer_than_two_nodes(self): + assert _dop([]) is None + assert _dop([_node((10.0, 0.0), (0.0, 10.0))]) is None + + def test_a_missing_config(self): + assert _dop([_node((10.0, 0.0), (0.0, 10.0)), None]) is None + + @pytest.mark.parametrize("key", ["rx_lat", "rx_lon", "tx_lat", "tx_lon"]) + def test_a_config_that_does_not_place_a_focus(self, key): + good = _node((10.0, 0.0), (0.0, 10.0)) + bad = dict(_node((-10.0, 5.0), (3.0, -12.0))) + bad[key] = None + + assert _dop([good, bad]) is None + + def test_a_focus_at_the_target(self): + good = _node((10.0, 0.0), (0.0, 10.0)) + on_top = _node((0.0, 0.0), (3.0, -12.0)) + + assert _dop([good, on_top]) is None diff --git a/backend/tests/test_solver_stats.py b/backend/tests/test_solver_stats.py index 6e18efde..147c5477 100644 --- a/backend/tests/test_solver_stats.py +++ b/backend/tests/test_solver_stats.py @@ -507,7 +507,7 @@ def test_known_lane_reflects_the_lane_counters(self): "publish_errors": 1, "reanchored": 0, "publish_rms_rejected": 2, - "publish_single_site": 0, + "publish_weak_geometry": 0, # Windowed, and empty here — these are since-boot counters bumped # directly, with no history records behind them. "position_error_km": {"median": None, "p90": None, "n": 0, "window_minutes": 10.0}, @@ -799,7 +799,7 @@ def test_known_lane_and_known_claims_blocks_present(self): "publish_errors", "reanchored", "publish_rms_rejected", - "publish_single_site", + "publish_weak_geometry", "position_error_km", } assert data["known_claims"].keys() == { diff --git a/docs/solverflow.md b/docs/solverflow.md index a5e76e81..aab97ca2 100644 --- a/docs/solverflow.md +++ b/docs/solverflow.md @@ -270,7 +270,7 @@ default — a typo should degrade to the inert mode, not the acting one): |---|---|---|---|---| | `off` | never runs | untouched | returns 0 immediately (`known_lane.run_known_lane_pass`); worker never even calls it (`solver._run_solver_worker`) | none | | `shadow` | runs, records claims + residuals + counters | untouched | runs: solves, classifies, records accuracy samples | never | -| `binding` | runs | `strip_claimed_detections` removes claimed indices (called from `frame_processor.process_one_frame`) | runs | `truth_match` results whose `rms_delay` passes `KNOWN_PUBLISH_MAX_RMS_DELAY_US` publish into `state.multinode_tracks` as `mn-adsb-`; ghosts never publish | +| `binding` | runs | `strip_claimed_detections` removes claimed indices (called from `frame_processor.process_one_frame`) | runs | `truth_match` results whose geometry passes `KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US` and whose `rms_delay` passes `KNOWN_PUBLISH_MAX_RMS_DELAY_US` publish into `state.multinode_tracks` as `mn-adsb-`; ghosts never publish | `strip_claimed_detections` (`services/known_claiming.py`) returns a copy with claimed indices removed from `delay`/`doppler`/`snr`/`adsb`; the @@ -314,15 +314,36 @@ flowchart TD glabel -->|"no"| gh["label = ghost
history known_ghost"] tm --> gpub{"mode == binding
AND label == truth_match?"} gh --> gpub - gpub -->|"yes"| grms{"rms_delay <=
KNOWN_PUBLISH_MAX_RMS_DELAY_US 3.0us?
(missing/None passes; n=2 carries 0.0)"} + gpub -->|"yes"| ggeo{"delay DOP at the fix <=
KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US 3.0 km/us?
(unknown geometry passes)"} + ggeo -->|"no"| weak["known_lane_publish_weak_geometry
publish_gate='weak_geometry'
accuracy sample STILL recorded"]:::inert + ggeo -->|"yes"| grms{"rms_delay <=
KNOWN_PUBLISH_MAX_RMS_DELAY_US 3.0us?
(missing/None passes; n=2 usually carries 0.0)"} grms -->|"no"| gated["known_lane_publish_rms_rejected
publish_gate='rms_delay'
accuracy sample STILL recorded"]:::inert grms -->|"yes"| publish["_publish: multinode_key_decision
-> mn-adsb-hex, smooth_solve,
supersession, solve_count+=1"] gpub -->|"no"| noop["accuracy sample only,
no feed entry"]:::inert ``` -The residual gate is on the PUBLISH alone: label, accuracy sample and history -outcome are identical whether it passes or not, so the free-solve measurement -below keeps measuring the solves it withholds. It exists because a +Both gates are on the PUBLISH alone: label, accuracy sample and history +outcome are identical whether they pass or not, so the free-solve measurement +below keeps measuring the solves they withhold. + +The geometry gate asks whether the claim set's delay loci cross sharply enough +at the aircraft to place it. Each node's delay puts the aircraft on a curve +whose normal is the sum of the unit vectors from its transmitter and from its +receiver; `services/solve_geometry.py` turns the spread of those normals into +a dilution of precision, km of horizontal error per microsecond of delay +error, evaluated at the transponder's position. It is not a property of the +receivers. Two on one roof share the receiver term, so their curves cross at +half the angle their transmitters subtend at the aircraft: a good pair when +the towers sit either side of it, none when they share a tower farm, and the +same holds for three or four on that roof. Receivers far apart fail it too +when the aircraft is distant enough that every focus lies one way. On test +(2026-10-02) the one real n=2 pair, co-sited with towers 18 km apart, was 3% +ghosts under 2 km/µs, 8% from 2 to 3, 33% from 3 to 4 and 61% over 8. The +gate replaced a rule that withheld every solve whose receivers shared a site, +which threw away that pair's good solves and passed separated receivers' +bad ones. + +The residual gate exists because a truth_match displacement was this lane's only publish check — on test and staging (2026-09-10/11) the tail of `mn-adsb-a85f17` (N6389R, a PA-28) put solves with `rms_delay` 9.9-11.2 µs and a formal `pos_sigma_km` of 1.9e7 on @@ -353,6 +374,7 @@ LM's SNR weighting maps to a uniform weight of 1.0. | `KNOWN_FOLLOW_MAX_AGE_S` / `KNOWN_FOLLOW_MIN_SOLVES` (follow candidates; **0 = off**) | 20.0 s / 3 | `known_claiming.py` | | `KNOWN_LANE_REANCHOR_STREAK` (**0 = off**) | 2 | `services/tasks/known_lane.py` | | `KNOWN_PUBLISH_MAX_RMS_DELAY_US` (publish gate only) | 3.0 µs (= `SOLVER_RMS_DELAY_MAX_US`) | `services/tasks/known_lane.py` | +| `KNOWN_PUBLISH_MAX_DELAY_DOP_KM_PER_US` (publish gate only; **0 = off**) | 3.0 km/µs | `services/tasks/known_lane.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` |