From 1131aa6e5db157b2903ce17f88b3818ac4adcc33 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Wed, 30 Sep 2026 23:56:32 +0000 Subject: [PATCH] Known lane: count co-sited receivers as one site, withhold single-site solves Two receivers within the co-location radius (node_sites, NODE_FUZZ_SITE_KM, 150 m) share the receiver focus of every bistatic ellipse they measure, so their loci cross at half the angle their transmitters subtend at the target. An n=2 from them is not the n=2 two sites give. On test, a271b0 (two receivers 56 m apart) solved 4.95, 2.32 and 1.90 km off. Over 21 min of live known-lane history, co-sited n=2 attempts were ghosts 43% of the time against 5% for two-site pairs, and the ones published sat a median 0.76 km off against 0.11 km. - node_sites.count_sites: the public-geometry grouping over a given node set, pure (no snapshot, no DB). - known_lane._count_sites: sites among the claim set, from the solve's own node configs; stamped as n_sites on history records and accuracy samples. - Binding publish gate "single_site" (counter known_lane_publish_single_site, reported in solver-stats), checked before the rms gate. Measurement, label and sample are unchanged. - Single-site ghosts never re-anchor: a degenerate geometry repeats its wrong answer, so self-agreement is no evidence. - Fix the comment claiming n=2 solves always carry rms_delay 0.0; the rms gate applies at every n (test pins the 3.59 us n=2 case). Co-Authored-By: Claude Opus 5.5 --- backend/core/state.py | 4 +- backend/services/node_sites.py | 24 ++++ backend/services/solver_report.py | 7 + backend/services/tasks/known_lane.py | 97 +++++++++++--- backend/tests/test_known_lane.py | 186 +++++++++++++++++++++++++++ backend/tests/test_node_sites.py | 40 ++++++ backend/tests/test_solver_stats.py | 2 + 7 files changed, 340 insertions(+), 20 deletions(-) diff --git a/backend/core/state.py b/backend/core/state.py index c1907628e..ab779f92c 100644 --- a/backend/core/state.py +++ b/backend/core/state.py @@ -745,7 +745,8 @@ 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. solver_report.py's known_lane block reads them as a funnel. +# 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. known_lane_attempts: int = 0 known_lane_truth_match: int = 0 known_lane_ghost: int = 0 @@ -754,6 +755,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 # 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 76408dca9..5fd62a5bc 100644 --- a/backend/services/node_sites.py +++ b/backend/services/node_sites.py @@ -394,6 +394,30 @@ def shared_sites() -> dict[str, list[str]]: return {anchor: sorted(ids) for anchor, ids in grouped.items() if len(ids) > 1} +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/solver_report.py b/backend/services/solver_report.py index b8c514936..6e27e17ba 100644 --- a/backend/services/solver_report.py +++ b/backend/services/solver_report.py @@ -553,6 +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_claims_made=state.known_claims_made, known_claim_contentions=state.known_claim_contentions, known_claims_bound=state.known_claims_bound, @@ -650,6 +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, # 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 b9f1b4f19..825ef6925 100644 --- a/backend/services/tasks/known_lane.py +++ b/backend/services/tasks/known_lane.py @@ -41,9 +41,13 @@ 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. The residual gate protects the MAP + 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 it passes or not, so the free-solve + 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. A SECOND PASS lives here too: the dark-follow lane (DARK_FOLLOW_MODE, see @@ -66,7 +70,7 @@ from config.constants import FT_TO_M from core import state -from services import dark_follow, track_filter +from services import dark_follow, node_sites, 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 @@ -385,14 +389,47 @@ def _reanchor(hexn: str, raw_lat: float, raw_lon: float, ts_ms: int) -> bool: return False -def _record_accuracy(hexn: str, err_km: float, label: str, n_nodes: int, ts_s: float) -> None: +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. + """ + 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) + + +def _record_accuracy(hexn: str, err_km: float, label: str, n_nodes: int, ts_s: float, n_sites: int) -> 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 as their own fields so later analysis can bin by + 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. Every converged outcome is *classified*, ghost included (a ghost's error @@ -430,6 +467,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, "ts": round(ts_s, 1), } ) @@ -494,6 +532,7 @@ 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) # 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 @@ -525,6 +564,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, **epoch_meta, }, ) @@ -537,32 +577,50 @@ 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. if ( label == "ghost" + and n_sites >= 2 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) - - # Residual gate on the PUBLISH only (see KNOWN_PUBLISH_MAX_RMS_DELAY_US). - # The regular lane's idiom verbatim, including its two silences: a missing - # or None rms_delay passes, and an n=2 solve — exactly determined, so it - # fits its own two equations perfectly and carries 0.0 — passes too. The - # displacement classifier therefore remains the only check n=2 has, as - # today; this gate can only take publishes away from the overdetermined - # solves whose residual actually means something. + _record_accuracy(hexn, err_km, label, n_nodes, s_in["timestamp_ms"] / 1000.0, n_sites) + + # 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, + # the regular lane's idiom. An n=2 solve is exactly determined (two + # delays and two Dopplers against lat, lon and two velocity components, + # altitude pinned), so it USUALLY fits its own equations and carries a + # residual of ~0 — but not always: when the two delay loci do not meet at + # the pinned altitude the solver settles on the nearest compromise and + # reports what it could not fit. a271b0 on test, 2026-09-30, published + # nothing from a co-sited pair at rms_delay 3.59 µs, and this gate is what + # withheld it; a nonzero n=2 residual is as disqualifying as any other. + # 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 - published = mode == "binding" and label in ("truth_match", "reanchored") and rms_ok + # 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 + publishable = mode == "binding" and label in ("truth_match", "reanchored") + published = publishable and sites_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 the + # 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. - gated = mode == "binding" and label in ("truth_match", "reanchored") and not rms_ok - if gated: + publish_gate = None + if publishable and not sites_ok: + publish_gate = "single_site" + state.bump_counter("known_lane_publish_single_site") + elif publishable and not rms_ok: + publish_gate = "rms_delay" state.bump_counter("known_lane_publish_rms_rejected") solve_key = None if published: @@ -595,7 +653,8 @@ def _attempt(hexn: str, s_in: dict, node_cfgs: dict, solve_fn, mode: str) -> Non "known_lane": True, "label": label, "published": published, - "publish_gate": "rms_delay" if gated else None, + "publish_gate": publish_gate, + "n_sites": n_sites, "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 1307414d9..596d6e908 100644 --- a/backend/tests/test_known_lane.py +++ b/backend/tests/test_known_lane.py @@ -70,6 +70,28 @@ "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). + "node_a2": { + "rx_lat": 40.7128 + 56.0 / 111_195.0, + "rx_lon": -74.0060, + "rx_alt_ft": 100, + "tx_lat": 40.80, + "tx_lon": -74.10, + "tx_alt_ft": 480, + "fc_hz": 100e6, + }, + "node_a3": { + "rx_lat": 40.7128 + 200.0 / 111_195.0, + "rx_lon": -74.0060, + "rx_alt_ft": 100, + "tx_lat": 40.80, + "tx_lon": -74.10, + "tx_alt_ft": 480, + "fc_hz": 100e6, + }, } # Target inside the node triangle: lat, lon, alt_km. @@ -517,6 +539,170 @@ def test_raising_the_bound_publishes_the_same_solve(self, monkeypatch): assert rec["published"] is True assert rec["publish_gate"] is None + def test_n2_residual_is_gated_like_any_other(self): + """The gate applies at n=2 too, and has to: an exactly determined n=2 + solve usually fits its own equations, but not when its delay loci do + not meet at the pinned altitude. a271b0 on test, 2026-09-30: a + truth_match at 1.90 km carrying rms_delay 3.59 µs, withheld.""" + _install(_mk_claims(["node_a", "node_b"])) + _run(_stub_solve(rms_delay=3.59), mode="binding") + + assert state.known_lane_truth_match == 1 + assert state.known_lane_published == 0 + assert state.known_lane_publish_rms_rejected == 1 + (rec,) = _known_records() + assert rec["n_nodes"] == 2 + assert rec["publish_gate"] == "rms_delay" + + +class TestSingleSiteGate: + """Claims from receivers at one site are one site's geometry, 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. + """ + + def test_cosited_pair_is_measured_but_not_published(self): + _install(_mk_claims(["node_a", "node_a2"])) + _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 + + # ...and off the map. + assert state.known_lane_published == 0 + assert state.known_lane_publish_single_site == 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["n_nodes"] == 2 + assert rec["n_sites"] == 1 + + 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"])) + _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 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") + + assert state.known_lane_publish_single_site == 1 + assert state.known_lane_publish_rms_rejected == 0 + (rec,) = _known_records() + assert rec["publish_gate"] == "single_site" + + def test_a_receiver_just_outside_the_radius_is_a_second_site(self): + _install(_mk_claims(["node_a", "node_a3"])) + _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["publish_gate"] is None + + 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"])) + _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"])) + _run(_stub_solve(), mode="binding") + + assert state.known_lane_published == 1 + (rec,) = _known_records() + assert rec["n_sites"] == 2 + + def test_a_node_without_a_position_is_its_own_site(self): + """The unknown case must never demote a solve.""" + _install(_mk_claims(["node_a", "node_a2"])) + 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 + + def test_shadow_mode_never_counts_a_rejection(self): + _install(_mk_claims(["node_a", "node_a2"])) + _run(_stub_solve()) + + assert state.known_lane_publish_single_site == 0 + (rec,) = _known_records() + assert rec["publish_gate"] is None + assert rec["n_sites"] == 1 + + def test_no_converge_record_carries_the_site_count(self): + _install(_mk_claims(["node_a", "node_a2"])) + _run(_stub_solve(success=False), mode="binding") + + (rec,) = _known_records() + assert rec["outcome"] == "known_no_converge" + assert rec["n_sites"] == 1 + + def test_single_site_ghosts_never_reanchor(self, monkeypatch): + """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.""" + monkeypatch.setattr(state, "SOLVER_EPOCH_ALIGN", False) + t0 = int(time.time() * 1000) + for i in range(3): + ts = t0 + i * 3000 + s_in = { + "initial_guess": {"lat": TARGET[0], "lon": TARGET[1], "alt_km": TARGET[2]}, + "seed_source": "kf", + "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") + ], + "n_nodes": 2, + "timestamp_ms": ts, + "adsb_hex": HEX, + "known_lane": True, + } + 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 + class TestPublishFailureContainment: """A publish that raises costs one hex its publish, never the pass. diff --git a/backend/tests/test_node_sites.py b/backend/tests/test_node_sites.py index 4f52db8b6..3bb55f874 100644 --- a/backend/tests/test_node_sites.py +++ b/backend/tests/test_node_sites.py @@ -443,3 +443,43 @@ 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_solver_stats.py b/backend/tests/test_solver_stats.py index 4605ababb..6e18efded 100644 --- a/backend/tests/test_solver_stats.py +++ b/backend/tests/test_solver_stats.py @@ -507,6 +507,7 @@ def test_known_lane_reflects_the_lane_counters(self): "publish_errors": 1, "reanchored": 0, "publish_rms_rejected": 2, + "publish_single_site": 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}, @@ -798,6 +799,7 @@ def test_known_lane_and_known_claims_blocks_present(self): "publish_errors", "reanchored", "publish_rms_rejected", + "publish_single_site", "position_error_km", } assert data["known_claims"].keys() == {