mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 11:57:57 +00:00
Refs #1268. #2209 is merged as `cab4f71c`, and master is merged into this branch (`47ce8264`), so the diff against master is this PR's own change: `ea4d1130` plus the fix in `5cbbe5d5`. ## What it does Two nodes hear each other only when SF and bandwidth match and their frequencies are close enough. `/api/rf-params` now also returns `deviations`: nodes whose setting differs from a majority of the nodes around them on the same band. - **Peers:** nodes with GPS and RF params within `rfParams.compareRadiusKm` (default 50) whose frequency is within 30 MHz. - **Same channel:** frequencies within a quarter of the bandwidth count as one channel (15.625 kHz at BW 62.5). The SX1262 tolerates a transmitter/receiver offset of up to 25% of BW without sensitivity loss, for SF5 to SF12 and all bandwidths. Source: the `FERR_L` row in the [S62F module datasheet](https://lora-alliance.org/wp-content/uploads/2020/07/S62F_SPEC_Ver.B.pdf), an SX1262 module; I did not find the figure in a Semtech document itself. That table also lists -50/-100/-200 ppm for SF12/SF11/SF10, which at 869 MHz is wider than BW/4 at 62.5 and 125 kHz, so BW/4 is the tighter bound there. - **Flagged when:** a setting the node cannot hear is shared by more than half of the group, the node itself counted, with at least 3 peers and no other incompatible setting at the same support. So 5 against 5 flags nobody, and 2 of 4 does not count as a majority. Each deviation carries the majority, how many peers are compatible with it, the peer count, and which of freq/sf/bw differ. - **Coding rate is not compared.** RadioLib starts SX126x radios with an explicit LoRa header (`SX126x::begin` sets `RADIOLIB_SX126X_LORA_HEADER_EXPLICIT` at `6d89348`, the commit the firmware pins in `platformio.ini`), and nothing in `firmware/src` switches to implicit. The header carries the CR, so a CR mismatch still decodes. I checked SX126x only, not SX127x or LR11x0. UI: - Nodes list: a badge such as `LoRa SF7` next to the name of a deviating node (the majority in its tooltip), and a `LoRa: differs from nearby (N)` filter kept in the URL as `?lora=differs`. - Node detail: one line, for example "Differs from 12 of 14 nearby nodes on this band, which use 869.618 MHz · SF8 · BW 62.5 kHz". - Badge and filter only exist while the server has map data. ## Why same-band only On our instance (1,110 nodes with params and GPS, measured with the first version of the rule), a plain neighbourhood majority flagged exactly 15 nodes, all of them a deliberate 433 MHz network in Limburg next to the 868 MHz one (names like `NL-ZAY-RP01 | 433 X 868`). The same-band comparison flags none there. On our network this PR shows nothing today. The flags are for instances where a node really is set to a different SF, bandwidth or channel than the nodes around it. ## Cost - Points are bucketed on a radius-sized grid (rows of radius/110.57 degrees, longitude columns widened by 1/cos(lat)) and compared with a squared equirectangular distance. - `BenchmarkRfDeviations1k` (1,100 nodes over Belgium and the southern Netherlands): 4.5 ms. `BenchmarkRfDeviations10k` (10,000 nodes in the same box): 388 ms, 1.3 MB. Both on the first version. - After `5cbbe5d5`, on a slower machine, 3 runs each: 1k 4.4 to 9.5 ms, 10k 821 to 852 ms, 1.8 MB. On that machine the first version measured 7.6 to 9.3 ms and 819 to 861 ms, so the majority step adds no measurable time. It is one pass over the distinct settings around a node, and those are few (one per preset in use). - Inside the radius the work is close to O(n²): 10,000 nodes in one dense box means about 2,000 neighbours per node, and a request after the 5 minute TTL waits for that computation. - The result is cached per map snapshot for 5 minutes; concurrent requests wait for one computation. - On staging `/api/perf` reports `/api/rf-params` at 9.9 ms average, 26 ms max (8 requests). ## Tests - Go: deviation cases (SF, freq within band, BW, CR ignored, other band ignored, no GPS, no params, far away), split vote and too few peers, radius, a peer in another longitude column at 60°N, the cache (same snapshot within TTL, new snapshot, after TTL), the route with four inserted nodes, plus the two benchmarks. - Go, added in `5cbbe5d5`: a 5/5 split and 2 of 4 flag nobody; a 1 and a 15 kHz offset are not deviations while 32 kHz is, and offset peers add up into one majority; the BW/4 boundary; peers two grid columns away at 60°N; a peer at the north edge of the radius. Each fails on the first version. The two-column test also fails with a ±1 column search, which the existing longitude test did not catch. - Full `cmd/server` suite and `test-all.sh` pass locally on `5cbbe5d5`. - Frontend unit: 6 new assertions for `deviationBadge` and the detail line (13 in the file). - E2E: 4 new checks: badge only on the deviating node, filter count and result, `?lora=differs` written and restored, detail line. The disabled check now also asserts no filter and no badges. 8/8 locally. ## Validation On staging with live map data, with the first version: 1,115 nodes served, `deviations: {}`, and the filter shows `LoRa: differs from nearby (0)` with no badges. Because no real deviation exists there, the badge, filter and detail line were checked by serving one simulated deviation through Playwright route interception against the staging page. ## Not done - No map marker for deviating nodes. - `compareRadiusKm` is config-only; the customizer is a later step. The other values are hardcoded and belong in that step too: at least 3 peers, the 30 MHz band, the more-than-half rule, BW/4 and the 5 minute TTL. - Not re-run on staging after `5cbbe5d5`, and the E2E suite was not re-run locally. The fix changes only the server's choice of nodes, not the UI. - The detail line says the peers "use" the majority setting. With the BW/4 tolerance, some of them may be a few kHz off it. - Companions and room servers are compared too, not only repeaters. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
8.1 KiB
Go
252 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// LoRa settings that differ from the surrounding nodes (issue #1268, M3).
|
|
//
|
|
// Two nodes only hear each other when spreading factor and bandwidth match
|
|
// and their frequencies are close enough. The coding rate does not have to: RadioLib starts SX126x
|
|
// radios with an explicit LoRa header (SX126x::begin sets
|
|
// RADIOLIB_SX126X_LORA_HEADER_EXPLICIT at the RadioLib commit the firmware
|
|
// pins), the firmware never switches to implicit, and the explicit header
|
|
// carries the coding rate, so a CR mismatch still decodes.
|
|
//
|
|
// A node is compared with the nodes within the radius on the same band only
|
|
// (frequency within rfSameBandMHz), so a deliberate 433 MHz network next to
|
|
// an 868 MHz one flags neither. It is flagged when a setting it cannot hear
|
|
// is shared by more than half of the group, the node itself counted, and no
|
|
// other incompatible setting has the same support.
|
|
//
|
|
// Frequencies within a quarter of the bandwidth count as the same channel:
|
|
// the SX1262 tolerates a transmitter/receiver offset of up to 25% of BW
|
|
// without sensitivity loss, for SF5 to SF12 and all bandwidths (FERR_L in the
|
|
// S62F SX1262 module datasheet, which repeats the chip's receiver table; at
|
|
// SF10-SF12 a -50/-100/-200 ppm limit is listed too, which at 869 MHz is
|
|
// wider than BW/4 for the 62.5 and 125 kHz presets).
|
|
|
|
const (
|
|
defaultRfCompareRadiusKm = 50.0
|
|
rfMinPeers = 3
|
|
// rfSameBandMHz treats frequencies this close as one band: 869.618 and
|
|
// 868.731 compare, 433.65 and 869.618 do not.
|
|
rfSameBandMHz = 30.0
|
|
// rfDeviationTTL bounds how long one computation is served. The map
|
|
// refreshes every few hours and nodes move rarely; 10,000 nodes in a
|
|
// Belgium-sized box take about 0.4 s (BenchmarkRfDeviations10k).
|
|
rfDeviationTTL = 5 * time.Minute
|
|
)
|
|
|
|
// rfDeviationCache holds the last computation for one snapshot.
|
|
type rfDeviationCache struct {
|
|
mu sync.Mutex
|
|
snap *rfParamsSnapshot
|
|
at time.Time
|
|
res map[string]RfDeviation
|
|
}
|
|
|
|
// get returns the cached deviations for snap, computing them when the
|
|
// snapshot changed or the entry is older than rfDeviationTTL. Concurrent
|
|
// callers wait for one computation instead of running their own.
|
|
func (c *rfDeviationCache) get(snap *rfParamsSnapshot, now time.Time, compute func() map[string]RfDeviation) map[string]RfDeviation {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.res != nil && c.snap == snap && now.Sub(c.at) < rfDeviationTTL {
|
|
return c.res
|
|
}
|
|
c.res, c.snap, c.at = compute(), snap, now
|
|
return c.res
|
|
}
|
|
|
|
func (c *RfParamsConfig) compareRadiusKm() float64 {
|
|
if c != nil && c.CompareRadiusKm > 0 {
|
|
return c.CompareRadiusKm
|
|
}
|
|
return defaultRfCompareRadiusKm
|
|
}
|
|
|
|
// RfSetting is the part of a node's radio settings that must match for two
|
|
// nodes to hear each other.
|
|
type RfSetting struct {
|
|
Freq float64 `json:"freq"`
|
|
SF int `json:"sf"`
|
|
BW float64 `json:"bw"`
|
|
}
|
|
|
|
// RfDeviation describes a node whose setting differs from the majority of
|
|
// its same-band peers within the radius.
|
|
type RfDeviation struct {
|
|
Majority RfSetting `json:"majority"`
|
|
Agree int `json:"agree"` // peers whose setting is compatible with Majority
|
|
Peers int `json:"peers"` // same-band peers within the radius
|
|
Fields []string `json:"fields"` // which of freq, sf, bw differ
|
|
}
|
|
|
|
type rfDevPoint struct {
|
|
pk string
|
|
lat, lon float64
|
|
set RfSetting
|
|
}
|
|
|
|
func rfSettingOf(p RfParams) RfSetting {
|
|
return RfSetting{Freq: math.Round(p.Freq*1000) / 1000, SF: p.SF, BW: p.BW}
|
|
}
|
|
|
|
// rfFreqCompatible reports whether two frequencies (MHz) are within a quarter
|
|
// of the bandwidth (kHz) of each other. The 1e-9 absorbs float rounding of
|
|
// the MHz values at exactly BW/4.
|
|
func rfFreqCompatible(a, b, bwKHz float64) bool {
|
|
return math.Abs(a-b) <= bwKHz/4000+1e-9
|
|
}
|
|
|
|
// compatible reports whether nodes on settings a and b can hear each other.
|
|
func (a RfSetting) compatible(b RfSetting) bool {
|
|
return a.SF == b.SF && a.BW == b.BW && rfFreqCompatible(a.Freq, b.Freq, a.BW)
|
|
}
|
|
|
|
// less orders settings so the majority pick does not depend on map order.
|
|
func (a RfSetting) less(b RfSetting) bool {
|
|
if a.Freq != b.Freq {
|
|
return a.Freq < b.Freq
|
|
}
|
|
if a.SF != b.SF {
|
|
return a.SF < b.SF
|
|
}
|
|
return a.BW < b.BW
|
|
}
|
|
|
|
// computeRfDeviations returns the deviating nodes, keyed by lower-case
|
|
// pubkey. Nodes without GPS or without RF parameters take no part. Points
|
|
// are bucketed on a grid of radius-sized cells, so each node is only
|
|
// measured against nodes in nearby cells.
|
|
func computeRfDeviations(params map[string]RfParams, nodes []nodeInfo, radiusKm float64, minPeers int) map[string]RfDeviation {
|
|
out := map[string]RfDeviation{}
|
|
if radiusKm <= 0 || len(params) == 0 {
|
|
return out
|
|
}
|
|
// 110.57 km is the shortest latitude degree (at the equator), so a row is
|
|
// never narrower than the radius and the ±1 row search covers it.
|
|
cellDeg := radiusKm / 110.57
|
|
type cell struct{ y, x int }
|
|
grid := map[cell][]int{}
|
|
pts := make([]rfDevPoint, 0, len(params))
|
|
for _, n := range nodes {
|
|
if !n.HasGPS {
|
|
continue
|
|
}
|
|
pk := strings.ToLower(n.PublicKey)
|
|
p, ok := params[pk]
|
|
if !ok {
|
|
continue
|
|
}
|
|
c := cell{int(math.Floor(n.Lat / cellDeg)), int(math.Floor(n.Lon / cellDeg))}
|
|
grid[c] = append(grid[c], len(pts))
|
|
pts = append(pts, rfDevPoint{pk: pk, lat: n.Lat, lon: n.Lon, set: rfSettingOf(p)})
|
|
}
|
|
|
|
// Squared-distance test on an equirectangular projection: within 50 km
|
|
// the error against haversine is far below the radius' own precision,
|
|
// and it avoids the trig per pair.
|
|
r2 := radiusKm * radiusKm
|
|
counts := map[RfSetting]int{}
|
|
for i, me := range pts {
|
|
// A longitude degree shrinks with cos(lat): widen the column search
|
|
// so the radius is still covered away from the equator.
|
|
cosLat := math.Cos(me.lat * math.Pi / 180)
|
|
kmPerLon := 111.32 * cosLat
|
|
dx := 1
|
|
if cosLat > 0.01 {
|
|
dx = int(math.Ceil(1 / cosLat))
|
|
} else {
|
|
dx = int(math.Ceil(360 / cellDeg))
|
|
}
|
|
cy, cx := int(math.Floor(me.lat/cellDeg)), int(math.Floor(me.lon/cellDeg))
|
|
for k := range counts {
|
|
delete(counts, k)
|
|
}
|
|
peers := 0
|
|
for y := cy - 1; y <= cy+1; y++ {
|
|
for x := cx - dx; x <= cx+dx; x++ {
|
|
for _, j := range grid[cell{y, x}] {
|
|
if j == i {
|
|
continue
|
|
}
|
|
o := pts[j]
|
|
if math.Abs(o.set.Freq-me.set.Freq) > rfSameBandMHz {
|
|
continue
|
|
}
|
|
dy := (o.lat - me.lat) * 110.57
|
|
dxk := (o.lon - me.lon) * kmPerLon
|
|
if dy*dy+dxk*dxk > r2 {
|
|
continue
|
|
}
|
|
counts[o.set]++
|
|
peers++
|
|
}
|
|
}
|
|
}
|
|
if peers < minPeers {
|
|
continue
|
|
}
|
|
best, support, ok := rfMajority(counts)
|
|
// Strict majority of the group, the node itself counted: 5 against 5
|
|
// flags neither side.
|
|
if !ok || support*2 <= peers+1 || best.compatible(me.set) {
|
|
continue
|
|
}
|
|
var fields []string
|
|
if !rfFreqCompatible(best.Freq, me.set.Freq, best.BW) {
|
|
fields = append(fields, "freq")
|
|
}
|
|
if best.SF != me.set.SF {
|
|
fields = append(fields, "sf")
|
|
}
|
|
if best.BW != me.set.BW {
|
|
fields = append(fields, "bw")
|
|
}
|
|
out[me.pk] = RfDeviation{Majority: best, Agree: support, Peers: peers, Fields: fields}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rfMajority picks the setting compatible with the most peers. support counts
|
|
// every peer compatible with it, so 869.617 and 869.618 add up. Among settings
|
|
// with equal support it prefers the one most peers use exactly, then the
|
|
// lowest. ok is false when another setting, incompatible with the pick, has
|
|
// the same support: then there is no majority to compare against. Distinct
|
|
// settings are few (one per preset in use), so the pairwise pass is cheap.
|
|
func rfMajority(counts map[RfSetting]int) (best RfSetting, support int, ok bool) {
|
|
sets := make([]RfSetting, 0, len(counts))
|
|
for s := range counts {
|
|
sets = append(sets, s)
|
|
}
|
|
sup := make([]int, len(sets))
|
|
for i, a := range sets {
|
|
for _, b := range sets {
|
|
if a.compatible(b) {
|
|
sup[i] += counts[b]
|
|
}
|
|
}
|
|
}
|
|
bi := -1
|
|
for i, a := range sets {
|
|
if bi < 0 || sup[i] > sup[bi] ||
|
|
(sup[i] == sup[bi] && (counts[a] > counts[sets[bi]] || (counts[a] == counts[sets[bi]] && a.less(sets[bi])))) {
|
|
bi = i
|
|
}
|
|
}
|
|
if bi < 0 {
|
|
return RfSetting{}, 0, false
|
|
}
|
|
for i, a := range sets {
|
|
if sup[i] == sup[bi] && !a.compatible(sets[bi]) {
|
|
return RfSetting{}, 0, false
|
|
}
|
|
}
|
|
return sets[bi], sup[bi], true
|
|
}
|