From c4e26487f19392426145ed993bba67b6ea030b33 Mon Sep 17 00:00:00 2001 From: kpa-clawbot Date: Mon, 22 Jun 2026 14:40:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(#1768):=20green=20=E2=80=94=20LoRa=20Time-o?= =?UTF-8?q?n-Air=20relay-airtime=20score=20+=20config=20preset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires `score = TimeOnAir(payloadBytes, preset) × distinctRelays` in cmd/server/relay_airtime_share.go (was bytes × relays). Surfaces the assumed PHY preset in the JSON response and the analytics dumbbell chart caption — share numbers are only meaningful relative to one preset (#1768 triage v1). Config: `analytics.loraPreset.{freq,bw,sf,cr}` under existing analytics block, defaults 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5 (matches the deployment's live `get radio` output). CRC=1, IH=0, DE derived from T_sym, preamble via firmware preambleLengthForSF — firmware-fixed constants intentionally NOT surfaced as config (per re-triage). Out of scope for this PR (v2 follow-ups per re-triage): - per-observation SF/BW + radio-settings-aware dedup - CR-per-hop dual-point sensitivity band Tests now pass (was red on commit 8da5706). cd cmd/server && go test -run RelayAirtime ./... → PASS cd internal/lora && go test ./... → PASS --- cmd/server/config.go | 15 ++++ cmd/server/go.mod | 4 + cmd/server/relay_airtime_share.go | 92 +++++++++++++++++++---- config.example.json | 7 ++ internal/lora/toa.go | 118 ++++++++++++++++++++++++++---- public/analytics.js | 20 ++++- 6 files changed, 224 insertions(+), 32 deletions(-) diff --git a/cmd/server/config.go b/cmd/server/config.go index da05686d..47ee9ce0 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -929,6 +929,21 @@ func (c *Config) IsObserverBlacklisted(id string) bool { type AnalyticsConfig struct { DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"` RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"` + // LoRaPreset is the assumed PHY preset used by the relay-airtime-share + // metric to compute true Time-on-Air (issue #1768). Defaults to the + // EU MeshCore deployment: 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5. + // freq is informational only and surfaces in the analytics caption. + LoRaPreset *LoRaPresetConfig `json:"loraPreset,omitempty"` +} + +// LoRaPresetConfig is the user-facing PHY preset for ToA scoring. +// Only the four free params live here; CRC/IH/DE are firmware-fixed +// in internal/lora and intentionally not surfaced as config. +type LoRaPresetConfig struct { + FreqHz float64 `json:"freq,omitempty"` // e.g. 869.6e6 + BWkHz float64 `json:"bw,omitempty"` // e.g. 62.5 + SF int `json:"sf,omitempty"` // e.g. 8 + CR int `json:"cr,omitempty"` // 5..8 (denominator suffix of 4/5..4/8) } // AnalyticsDefaultRecomputeInterval returns the configured default diff --git a/cmd/server/go.mod b/cmd/server/go.mod index 55a34d7f..ae93a43f 100644 --- a/cmd/server/go.mod +++ b/cmd/server/go.mod @@ -30,6 +30,10 @@ require github.com/meshcore-analyzer/dbschema v0.0.0 replace github.com/meshcore-analyzer/dbschema => ../../internal/dbschema +require github.com/meshcore-analyzer/lora v0.0.0 + +replace github.com/meshcore-analyzer/lora => ../../internal/lora + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 65b3c7b6..d599052e 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -3,25 +3,80 @@ package main import ( "sort" "time" + + "github.com/meshcore-analyzer/lora" ) -// relay_airtime_share.go — issue #1359 +// relay_airtime_share.go — issues #1359 + #1768 // // Implements the "Relay Airtime Share" analytics metric: -// score(packet) = payload_bytes × COUNT(DISTINCT repeater_pubkey -// across all observations of that packet) +// score(packet) = TimeOnAir(payload_bytes, preset) +// × COUNT(DISTINCT repeater_pubkey across observations) +// +// #1768 swapped the original byte-only proxy (`bytes × relays`) for +// closed-form LoRa Time-on-Air. The byte proxy underweighted small +// frames by ~3-4× because the additive preamble + fixed-symbol +// intercept does NOT cancel under per-type normalization. ToA fixes +// the headline divergence the dumbbell chart is supposed to show. +// +// The PHY preset is config-driven (analytics.loraPreset in +// config.example.json); defaults match the actual deployment +// preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the +// SF-dependent preamble pulled from internal/lora.PreambleForSF. // // Aggregated by payload_type. Originator TX is deliberately excluded — a // never-relayed direct message scores 0, which is the correct framing for a -// "relay amplification" metric. -// -// In-memory only; no SQL, no new index, no schema change. The resolved-pubkey -// reverse index (populated under s.mu via addToResolvedPubkeyIndex from every -// observation's resolved_path) is the source of distinct relays per -// transmission — len(resolvedPubkeyReverse[tx.ID]) IS the union of distinct -// repeater pubkeys, deduplicated cross-observation. Critical: this is NOT the -// length of any single observation's resolved_path (the bug-trap from -// #1358's follow-up SQL hint). +// "relay amplification" metric. In-memory only; no SQL, no new index. + +// defaultLoRaPreset is the canonical fallback when config is absent. +// Matches the reporter's `get radio` output `869.6179809, 62.5, 8, 5`. +func defaultLoRaPreset() lora.Preset { + return lora.Preset{ + FreqHz: 869.6e6, + BWkHz: 62.5, + SF: 8, + CR: 5, + Preamble: lora.PreambleForSF(8), + } +} + +// resolveLoRaPreset returns the effective preset, falling back to +// defaults for any unset / zero / out-of-range field. +func (s *PacketStore) resolveLoRaPreset() lora.Preset { + p := defaultLoRaPreset() + if s == nil || s.config == nil || s.config.Analytics == nil || s.config.Analytics.LoRaPreset == nil { + return p + } + cfg := s.config.Analytics.LoRaPreset + if cfg.FreqHz > 0 { + p.FreqHz = cfg.FreqHz + } + if cfg.BWkHz > 0 { + p.BWkHz = cfg.BWkHz + } + if cfg.SF >= 6 && cfg.SF <= 12 { + p.SF = cfg.SF + p.Preamble = lora.PreambleForSF(cfg.SF) + } + if cfg.CR >= 5 && cfg.CR <= 8 { + p.CR = cfg.CR + } + return p +} + +// presetJSON shapes the preset for the API response and the +// analytics caption (issue #1768 — operators can't interpret an +// "Airtime %" headline without knowing what PHY assumptions it bakes +// in). All four free params plus the derived preamble are surfaced. +func presetJSON(p lora.Preset) map[string]interface{} { + return map[string]interface{}{ + "freq_hz": p.FreqHz, + "bw_khz": p.BWkHz, + "sf": p.SF, + "cr": p.CR, + "preamble": p.Preamble, + } +} // distinctRelayCount returns the number of distinct repeater pubkeys that // forwarded `tx`, unioned across ALL observations of that transmission_id. @@ -55,15 +110,16 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int defer s.mu.RUnlock() ptNames := payloadTypeNames + preset := s.resolveLoRaPreset() type bucket struct { count int - score int + score int64 // sum of ToA(payload) × relays, in nanoseconds } buckets := make(map[int]*bucket) seenHash := make(map[string]bool, len(s.packets)) totalCount := 0 - totalScore := 0 + var totalScore int64 for _, tx := range s.packets { if tx == nil || tx.PayloadType == nil { @@ -90,10 +146,13 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int b.count++ totalCount++ - // payload bytes from RawHex (2 hex chars per byte). + // payload bytes from RawHex (2 hex chars per byte). Score is + // LoRa Time-on-Air (nanoseconds) × distinct relays — see + // resolveLoRaPreset for the assumed PHY block (issue #1768). payloadBytes := len(tx.RawHex) / 2 relays := s.distinctRelayCount(tx) - score := payloadBytes * relays + toa := lora.TimeOnAir(payloadBytes, preset) + score := int64(toa) * int64(relays) b.score += score totalScore += score } @@ -147,6 +206,7 @@ func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]int "rows": rows, "total_count": totalCount, "total_score": totalScore, + "preset": presetJSON(preset), "window": label, "cached": false, } diff --git a/config.example.json b/config.example.json index ec797097..13e53b50 100644 --- a/config.example.json +++ b/config.example.json @@ -383,6 +383,13 @@ "roles": 300, "observersClockSkew": 300, "nodesClockSkew": 300 + }, + "loraPreset": { + "freq": 869600000, + "bw": 62.5, + "sf": 8, + "cr": 5, + "_comment_": "Issue #1768. LoRa PHY preset assumed by the Relay Airtime Share metric to compute true Time-on-Air. Share numbers are only meaningful relative to one preset, so operators MUST set this to match their mesh — freq is informational (surfaces in the chart caption) and does not affect ToA; bw is bandwidth in kHz (e.g. 62.5, 125, 250); sf is spreading factor (6..12); cr is the denominator of the 4/N coding rate (5 ⇒ 4/5 … 8 ⇒ 4/8). Defaults reproduce the typical EU MeshCore deployment 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5. CRC=1, IH=0, DE (T_sym ≥ 16 ms), and preamble (32 for SF≤8, 16 otherwise, per firmware preambleLengthForSF) are firmware-fixed and intentionally not exposed as config." } }, "_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.", diff --git a/internal/lora/toa.go b/internal/lora/toa.go index 948339b5..2c070de7 100644 --- a/internal/lora/toa.go +++ b/internal/lora/toa.go @@ -1,25 +1,113 @@ // Package lora implements closed-form LoRa Time-on-Air calculations. // -// Issue #1768 — see toa.go for the final implementation. This file -// holds the public API stub used by the failing red commit; the green -// commit replaces TimeOnAir's body with the closed-form expression. +// Issue #1768 — replaces the bytes-only proxy in +// cmd/server/relay_airtime_share.go with a true ToA estimate so the +// "Airtime %" headline metric is no longer biased ~3-4× against small +// frames by the preamble + fixed-symbol intercept. +// +// Reference: Semtech AN1200.13 / SX126x datasheet v1.1 §6.1.4. +// Cross-checked against RadioLib calculateTimeOnAir() (issue #1768 +// discussion); MeshCore-specific constants: +// +// - CRC = 1 (setCRC(1) in MeshCore drivers) +// - IH = 0 (explicit-header default, never overridden) +// - DE = 1 iff T_sym ≥ 16 ms (per SX126x_commands.cpp:224) +// +// Preamble follows MeshCore's preambleLengthForSF (firmware +// RadioLibWrappers.h:47, MeshCore PR #1954): 32 symbols for SF≤8, 16 +// otherwise. Callers should pass PreambleForSF(sf) when modeling the +// MeshCore default; if Preset.Preamble is zero TimeOnAir falls back to +// the LoRa-protocol default 8. package lora -import "time" +import ( + "math" + "time" +) // Preset captures the LoRa PHY parameters needed to compute ToA. +// FreqHz is informational only (recorded for the analytics caption) +// and does not enter the ToA formula. type Preset struct { - FreqHz float64 - BWkHz float64 - SF int - CR int - Preamble int + FreqHz float64 // e.g. 869.6e6 — informational only + BWkHz float64 // bandwidth in kHz (e.g. 62.5, 125, 250) + SF int // spreading factor (6..12) + CR int // coding-rate denominator suffix: 5 ⇒ 4/5 … 8 ⇒ 4/8 + Preamble int // preamble symbols; 0 ⇒ LoRa default 8 } -// PreambleForSF returns MeshCore's SF-dependent preamble length. -// Stub: returns 0 in the red commit. -func PreambleForSF(sf int) int { return 0 } +// PreambleForSF returns MeshCore's SF-dependent preamble length: +// 32 symbols for SF≤8, 16 otherwise. Mirrors firmware +// preambleLengthForSF (RadioLibWrappers.h:47, PR #1954). +func PreambleForSF(sf int) int { + if sf <= 8 { + return 32 + } + return 16 +} -// TimeOnAir returns the LoRa time-on-air for a payload. -// Stub: returns 0 in the red commit. -func TimeOnAir(payloadBytes int, preset Preset) time.Duration { return 0 } +// TimeOnAir returns the LoRa time-on-air for a payload of payloadBytes +// transmitted with the given preset. +// +// Closed form (Semtech AN1200.13 / SX126x §6.1.4) with MeshCore +// constants CRC=1, IH=0: +// +// T_sym = 2^SF / BW_Hz +// DE = 1 if T_sym ≥ 16 ms else 0 +// n_payload = 8 + max(ceil((8·PL − 4·SF + 28 + 16·CRC − 20·IH) / +// (4·(SF − 2·DE))) · coding_coeff, 0) +// +// coding_coeff = preset.CR directly (we encode the denominator 5..8 +// so coefficient = denominator; Semtech notation uses CR ∈ 1..4 with +// coefficient = CR+4, which is the same arithmetic). +// n_preamble = preamble + 4.25 +// ToA = (n_preamble + n_payload) · T_sym +// +// Invalid presets (SF outside 6..12, BW≤0, CR outside 5..8, negative +// payload) return 0 so callers can guard cheaply on a zero result. +func TimeOnAir(payloadBytes int, preset Preset) time.Duration { + if payloadBytes < 0 { + return 0 + } + if preset.SF < 6 || preset.SF > 12 { + return 0 + } + if preset.BWkHz <= 0 { + return 0 + } + if preset.CR < 5 || preset.CR > 8 { + return 0 + } + preamble := preset.Preamble + if preamble <= 0 { + preamble = 8 + } + + bwHz := preset.BWkHz * 1000.0 + tSym := math.Exp2(float64(preset.SF)) / bwHz // seconds per symbol + de := 0 + if tSym*1000.0 >= 16.0 { + de = 1 + } + + // CRC=1, IH=0 → constant +28 + 16*1 − 20*0 = +44. + num := 8*payloadBytes - 4*preset.SF + 28 + 16 + den := 4 * (preset.SF - 2*de) + if den <= 0 { + return 0 + } + var symbolsPayload float64 + if num <= 0 { + // Closed-form clamps to 8-symbol minimum payload header + // (matches RadioLib for tiny payloads). + symbolsPayload = 8 + } else { + ceilTerm := math.Ceil(float64(num) / float64(den)) + symbolsPayload = 8 + ceilTerm*float64(preset.CR) + } + + preambleSymbols := float64(preamble) + 4.25 + totalSymbols := preambleSymbols + symbolsPayload + secs := totalSymbols * tSym + return time.Duration(secs * float64(time.Second)) +} diff --git a/public/analytics.js b/public/analytics.js index fa4d0b9f..4b13d194 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -474,9 +474,27 @@ if (totalScore <= 0) { return '
No relay activity observed in this window (all packets direct).
'; } + // Issue #1768 — surface the LoRa preset baked into the ToA score. Share + // numbers are only meaningful relative to one PHY preset; operators must + // know what was assumed. + var preset = data && data.preset; + var presetCaption = ''; + if (preset && typeof preset === 'object') { + var freqMHz = Number(preset.freq_hz || 0) / 1e6; + presetCaption = + '
' + + 'Assumed LoRa preset: ' + + (freqMHz ? freqMHz.toFixed(3) + ' MHz / ' : '') + + 'BW ' + Number(preset.bw_khz || 0) + ' kHz / ' + + 'SF ' + Number(preset.sf || 0) + ' / ' + + 'CR 4/' + Number(preset.cr || 0) + + ' (preamble ' + Number(preset.preamble || 0) + ' sym)' + + '
'; + } // Layout: per row → label | track 0..100% | values var palette = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16']; var html = '
'; + if (presetCaption) html += presetCaption; rows.forEach(function (r, i) { var name = r.payload_type || 'UNK'; var cnt = Number(r.count || 0); @@ -491,7 +509,7 @@ name + '\n' + 'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' + 'Airtime: ' + apct.toFixed(2) + '% (score ' + score.toLocaleString() + ')\n' + - 'Score = bytes × distinct repeaters. Within-mesh only.'; + 'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'; html += '
' + '
' + esc(name) + '
' + '
' +