Files
meshcore-analyzer/cmd/server/rx_coverage_gaps.go
T
efitenandClaude Opus 5.5 f3004d977e feat(rx-coverage): show where a companion drove and received nothing (#2172)
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>
2026-10-10 17:39:33 +02:00

115 lines
3.9 KiB
Go

package main
import (
"sort"
"strings"
"time"
)
// "Nothing received" cells for the coverage map: hex cells a companion drove
// through (moving client_rf_samples) in which no client_receptions row lies in
// the same window. Served as the `gaps` foreign member of /api/rx-coverage when
// asked with ?gaps=1, so the reception features stay exactly what they were.
//
// One moving sample is enough. Measured on the live network (2026-09/10): of
// cells flagged with nothing received in one half of the period and driven again
// in the other, 29% (1 sample), 32% (2), 35% (3) and 40% (5) did have a
// reception the second time. A higher threshold only hides cells; it does not
// make the rest more reliable. Hence the wording "nothing received in this
// period", never "no coverage".
// coverageGapCap bounds the gap cells per response, like coverageFeatureCap
// and rfNoiseFeatureCap.
const coverageGapCap = 5000
type CoverageGap struct {
Type string `json:"type"` // "Feature"
Geometry CoveragePolygon `json:"geometry"`
Properties CoverageGapProperties `json:"properties"`
}
type CoverageGapProperties struct {
Cell string `json:"cell"`
Samples int `json:"samples"` // moving track samples in the cell
}
// trackPoint is one client_rf_samples GPS fix: the companion was connected
// to the app at that place and time.
type trackPoint struct {
Lat, Lon float64
Stationary bool
}
// aggregateGaps returns the cells with moving track samples and no reception,
// sorted by cell. When more than coverageGapCap qualify, the most-sampled are
// kept and truncated is true.
func aggregateGaps(track []trackPoint, receptions []coverageRow, res int) (gaps []CoverageGap, truncated bool) {
heard := make(map[string]bool, len(receptions))
for _, r := range receptions {
heard[hexCellAt(r.Lat, r.Lon, res)] = true
}
samples := map[string]int{}
for _, p := range track {
if p.Stationary {
continue
}
if c := hexCellAt(p.Lat, p.Lon, res); !heard[c] {
samples[c]++
}
}
gaps = []CoverageGap{}
for cell, n := range samples {
ring := hexBoundary(cell)
if ring == nil {
continue
}
gaps = append(gaps, CoverageGap{
Type: "Feature",
Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}},
Properties: CoverageGapProperties{Cell: cell, Samples: n},
})
}
if len(gaps) > coverageGapCap {
sort.Slice(gaps, func(i, j int) bool {
if gaps[i].Properties.Samples != gaps[j].Properties.Samples {
return gaps[i].Properties.Samples > gaps[j].Properties.Samples
}
return gaps[i].Properties.Cell < gaps[j].Properties.Cell
})
gaps, truncated = gaps[:coverageGapCap], true
}
sort.Slice(gaps, func(i, j int) bool { return gaps[i].Properties.Cell < gaps[j].Properties.Cell })
return gaps, truncated
}
// queryTrackPoints returns client_rf_samples GPS fixes within a bbox and time
// window (days; 0 = all time), optionally for one companion. Unlike the noise
// layer it keeps rows without a noise_floor reading: any sample proves the
// companion was connected there. Read-only.
func (s *Server) queryTrackPoints(rx string, days int, b bbox) ([]trackPoint, error) {
where := []string{"lat BETWEEN ? AND ?", "lon BETWEEN ? AND ?", "stationary = 0"}
args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon}
if rx != "" {
where = append(where, "rx_pubkey = ?")
args = append(args, strings.ToLower(rx))
}
if days > 0 {
where = append(where, "sampled_at >= ?")
args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339))
}
rows, err := s.db.conn.Query("SELECT lat, lon FROM client_rf_samples WHERE "+strings.Join(where, " AND "), args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []trackPoint{}
for rows.Next() {
var p trackPoint
if err := rows.Scan(&p.Lat, &p.Lon); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}