mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 12:57:17 +00:00
The Mobile RX coverage page (`#/rx-coverage`) shows only cells where a companion received something. Where a companion drove and received nothing is just as useful for operators, and the data is already there: the RF sample track (`client_rf_samples`, #1905/#1906) and the receptions (`client_receptions`) use the same hex grid (`hexCellAt`, `zoomToHexRes`). This PR adds those cells as a hatched layer under the reception cells. ## Changes in this PR **Server** - `GET /api/rx-coverage?...&gaps=1` adds a `gaps` member to the FeatureCollection: cells with moving (non-stationary) track samples and no reception in the same `days` window and `bbox`, each with `samples`. `gaps_truncated` is set when more than 5000 cells qualify (the most-sampled are kept, same pattern as `coverageFeatureCap` and `rfNoiseFeatureCap`). - Gaps are only returned when `clientRfSamples` is on and there is no `node` filter: with `?node=` the question would be "this node was not heard", which these cells do not answer. `?rx=` limits both the track and the receptions to that companion. - Without `gaps=1` the payload is unchanged: the `gaps` member is omitted. - New code lives in `cmd/server/rx_coverage_gaps.go` (pure `aggregateGaps` plus `queryTrackPoints`); the handler change in `rx_dashboard.go` is 14 lines. - `/api/rx-coverage` is now documented in `openapi.go` and removed from `openapi_known_gaps.json` (#1670). **Frontend** (`public/rx-coverage.js`, `public/node-reach-coverage.css`) - A "Nothing received" toggle on the signal layer, on by default, only rendered with `MC_CLIENT_RF_SAMPLES`. Hidden on the noise layer. - Gap cells use a 45-degree hatch (an SVG pattern in a dedicated renderer, so they sit under the reception cells). The hatch is denser for cells driven through more often (1 / 2-3 / 4+ samples). Tooltip: sample count. Legend entry: "driven, nothing received". - Off state is deep-linkable as `gaps=0`. - New token `--nq-cov-gap` for light and dark. Plain grey (`--nq-cov-grey`) already means "received, no SNR", so the hatch keeps the two apart. ## Threshold: one sample Measured on a live network (analyzer.on8ar.eu, Belgium, September to October 2026), with cells of about 250 m: a cell with track samples and no reception in one half of a month, driven again in the other half. How often did it receive something the second time? | Minimum samples | Cells "nothing received" | Driven again | Received something later | |---|---|---|---| | 1 | 3971 | 1263 | 29% | | 2 | 1565 | 592 | 32% | | 3 | 619 | 243 | 35% | | 5 | 142 | 63 | 40% | A higher threshold hides cells without making the rest more reliable, so the threshold is 1 and the wording is "nothing received in this period", never "no coverage". Using every decoded packet as reception evidence (including 1-byte path hashes) gave 25% to 32%, so the capture rule for 1-byte hashes is not what drives this. The median cell has 2 samples. ## Verification - Go: 7 new tests in `rx_coverage_gaps_test.go` (gap/no gap, stationary excluded, single sample, cap keeps the most-sampled, endpoint, `?rx=` filter, no `gaps` member when not asked / with `?node=` / with RF samples off). The endpoint tests fail without the handler change. Full `cmd/server` suite green locally, `go vet` and `gofmt` clean. - E2E: `tests/e2e/test-rx-coverage-gaps-e2e.js` (4 steps: no toggle with RF samples off, on by default with hatched cells under the reception cells, toggle off with `gaps=0` and deep link, noise layer hides it). 2 steps fail without the frontend change. Existing `test-rx-coverage-noise-e2e.js` and `test-rx-coverage-viewport-e2e.js` still pass. Listed in `scripts/non-unit-tests.json`. - Frontend unit suite (`test-all.sh`) green. - Real data on a staging instance (7 days, bbox 50.8,4.9,51.3,5.8, z=12): 478 reception cells and 379 gap cells, no cell in both, reception features identical with and without `gaps=1`, 0.48 s to 0.52 s per request both ways. - Browser: checked on staging with real data (light theme), and light, dark and 390 px wide with mocked data. ## Not in this PR - A per-node variant on the Reach page ("this repeater transmitted while a companion was nearby, and was not heard"). That needs the transmit moments from `observations` and is a heavier query. - `deploy.yml` does not get a line for the new E2E file, like the other entries that are only in `non-unit-tests.json` (#2037). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
// --- aggregateGaps: pure ---
|
|
|
|
func TestAggregateGapsTrackWithoutReception(t *testing.T) {
|
|
res := zoomToHexRes(12)
|
|
track := []trackPoint{{Lat: 51.05, Lon: 3.72}, {Lat: 51.05, Lon: 3.72}, {Lat: 51.30, Lon: 3.90}}
|
|
rx := []coverageRow{{Lat: 51.30, Lon: 3.90}}
|
|
gaps, truncated := aggregateGaps(track, rx, res)
|
|
if truncated {
|
|
t.Fatal("two cells must not be truncated")
|
|
}
|
|
if len(gaps) != 1 {
|
|
t.Fatalf("want 1 gap (the cell without a reception), got %d: %+v", len(gaps), gaps)
|
|
}
|
|
g := gaps[0]
|
|
if g.Type != "Feature" || g.Geometry.Type != "Polygon" || len(g.Geometry.Coordinates[0]) == 0 {
|
|
t.Fatalf("gap must be a GeoJSON polygon feature, got %+v", g)
|
|
}
|
|
if g.Properties.Cell != hexCellAt(51.05, 3.72, res) || g.Properties.Samples != 2 {
|
|
t.Fatalf("want the 51.05/3.72 cell with 2 samples, got %+v", g.Properties)
|
|
}
|
|
}
|
|
|
|
// A parked companion is not a drive through the cell: stationary samples never make a gap.
|
|
func TestAggregateGapsIgnoresStationary(t *testing.T) {
|
|
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72, Stationary: true}}, nil, zoomToHexRes(12))
|
|
if len(gaps) != 0 {
|
|
t.Fatalf("stationary sample must not produce a gap, got %+v", gaps)
|
|
}
|
|
}
|
|
|
|
// One moving sample is enough (threshold measured: more samples do not make a gap more reliable).
|
|
func TestAggregateGapsSingleSample(t *testing.T) {
|
|
gaps, _ := aggregateGaps([]trackPoint{{Lat: 51.05, Lon: 3.72}}, nil, zoomToHexRes(12))
|
|
if len(gaps) != 1 || gaps[0].Properties.Samples != 1 {
|
|
t.Fatalf("one moving sample without reception must be a gap, got %+v", gaps)
|
|
}
|
|
}
|
|
|
|
func TestAggregateGapsCapsKeepsMostSampled(t *testing.T) {
|
|
res := zoomToHexRes(14)
|
|
var track []trackPoint
|
|
seen := map[string]bool{}
|
|
for i := 0; len(seen) < coverageGapCap+5; i++ {
|
|
la, lo := 51.0+float64(i%200)*0.002, 3.5+float64(i/200)*0.003
|
|
c := hexCellAt(la, lo, res)
|
|
if seen[c] {
|
|
continue
|
|
}
|
|
seen[c] = true
|
|
track = append(track, trackPoint{Lat: la, Lon: lo})
|
|
if len(seen) == 1 { // one cell gets many samples: it must survive the cap
|
|
for k := 0; k < 9; k++ {
|
|
track = append(track, trackPoint{Lat: la, Lon: lo})
|
|
}
|
|
}
|
|
}
|
|
gaps, truncated := aggregateGaps(track, nil, res)
|
|
if !truncated || len(gaps) != coverageGapCap {
|
|
t.Fatalf("want %d gaps and truncated, got %d truncated=%v", coverageGapCap, len(gaps), truncated)
|
|
}
|
|
found := false
|
|
for _, g := range gaps {
|
|
if g.Properties.Samples == 10 {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("the most-sampled cell must be kept when capping")
|
|
}
|
|
for i := 1; i < len(gaps); i++ {
|
|
if gaps[i-1].Properties.Cell > gaps[i].Properties.Cell {
|
|
t.Fatal("gaps must be sorted by cell for a deterministic payload")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- endpoint ---
|
|
|
|
func seedGapsDB(t *testing.T) *DB {
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `CREATE TABLE client_rf_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT, sampled_at TEXT, ingested_at TEXT,
|
|
lat REAL, lon REAL, pos_acc_m REAL, stationary INTEGER NOT NULL DEFAULT 0,
|
|
uptime_secs INTEGER, battery_mv INTEGER, queue_len INTEGER, errors INTEGER,
|
|
noise_floor INTEGER, last_rssi INTEGER, last_snr REAL, tx_air_secs INTEGER,
|
|
rx_air_secs INTEGER, recv INTEGER, sent INTEGER, flood_rx INTEGER, direct_rx INTEGER,
|
|
flood_tx INTEGER, direct_tx INTEGER, recv_errors INTEGER)`)
|
|
return db
|
|
}
|
|
|
|
func insTrack(t *testing.T, db *DB, rx, at string, lat, lon float64, stationary int) {
|
|
mustExecDB(t, db, fmt.Sprintf(
|
|
`INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs) VALUES ('%s','%s','t',%f,%f,%d,1)`,
|
|
rx, at, lat, lon, stationary))
|
|
}
|
|
|
|
func serveCoverage(srv *Server, path string) *httptest.ResponseRecorder {
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/api/rx-coverage", srv.handleRxCoverage).Methods("GET")
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
|
|
return rr
|
|
}
|
|
|
|
func gapsServer(t *testing.T, rfOn bool) *Server {
|
|
db := seedGapsDB(t)
|
|
// seedCoverageDB already holds receptions; start from a known state.
|
|
mustExecDB(t, db, `DELETE FROM client_receptions`)
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
old := time.Now().UTC().AddDate(0, 0, -40).Format(time.RFC3339)
|
|
insRx(t, db, "compa", "aabbcc", now, 51.30, 3.90) // heard here
|
|
insTrack(t, db, "compa", now, 51.30, 3.90, 0) // driven and heard: no gap
|
|
insTrack(t, db, "compa", now, 51.05, 3.72, 0) // driven, nothing: gap
|
|
insTrack(t, db, "compb", now, 51.10, 3.60, 0) // other companion, nothing: gap unless ?rx=compa
|
|
insTrack(t, db, "compa", now, 51.20, 3.50, 1) // parked: no gap
|
|
insTrack(t, db, "compa", old, 51.40, 3.40, 0) // outside the 7-day window: no gap
|
|
cfg := &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}
|
|
if rfOn {
|
|
cfg.ClientRfSamples = &ClientRfSamplesConfig{Enabled: true}
|
|
}
|
|
return &Server{db: db, cfg: cfg}
|
|
}
|
|
|
|
func decodeCoverage(t *testing.T, rr *httptest.ResponseRecorder) CoverageFeatureCollection {
|
|
t.Helper()
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
|
|
}
|
|
var fc CoverageFeatureCollection
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
return fc
|
|
}
|
|
|
|
func TestRxCoverageGapsEndpoint(t *testing.T) {
|
|
srv := gapsServer(t, true)
|
|
z := 12
|
|
fc := decodeCoverage(t, serveCoverage(srv, fmt.Sprintf("/api/rx-coverage?bbox=50,3,52,4&z=%d&days=7&gaps=1", z)))
|
|
if len(fc.Features) != 1 {
|
|
t.Fatalf("the reception layer must be unchanged: want 1 feature, got %d", len(fc.Features))
|
|
}
|
|
res := zoomToHexRes(z)
|
|
want := map[string]bool{hexCellAt(51.05, 3.72, res): true, hexCellAt(51.10, 3.60, res): true}
|
|
if len(fc.Gaps) != len(want) {
|
|
t.Fatalf("want %d gaps, got %+v", len(want), fc.Gaps)
|
|
}
|
|
for _, g := range fc.Gaps {
|
|
if !want[g.Properties.Cell] {
|
|
t.Fatalf("unexpected gap cell %s", g.Properties.Cell)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRxCoverageGapsFollowObserverFilter(t *testing.T) {
|
|
srv := gapsServer(t, true)
|
|
fc := decodeCoverage(t, serveCoverage(srv, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&rx=compa"))
|
|
if len(fc.Gaps) != 1 || fc.Gaps[0].Properties.Cell != hexCellAt(51.05, 3.72, zoomToHexRes(12)) {
|
|
t.Fatalf("?rx=compa must only show compa's own drive, got %+v", fc.Gaps)
|
|
}
|
|
}
|
|
|
|
// Without ?gaps=1, with a node filter, or with the RF sample stream off, the payload carries no gaps member at all.
|
|
func TestRxCoverageGapsOnlyWhenAskedAndAvailable(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
rfOn bool
|
|
path string
|
|
}{
|
|
{"not asked", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7"},
|
|
{"node filter", true, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1&node=aabbcc"},
|
|
{"rf samples off", false, "/api/rx-coverage?bbox=50,3,52,4&z=12&days=7&gaps=1"},
|
|
}
|
|
for _, c := range cases {
|
|
rr := serveCoverage(gapsServer(t, c.rfOn), c.path)
|
|
if rr.Code != 200 {
|
|
t.Fatalf("%s: status %d", c.name, rr.Code)
|
|
}
|
|
if strings.Contains(rr.Body.String(), `"gaps"`) {
|
|
t.Fatalf("%s: payload must not carry gaps, got %s", c.name, rr.Body.String())
|
|
}
|
|
}
|
|
}
|