fix(#1768): green — LoRa Time-on-Air relay-airtime score + config preset

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
This commit is contained in:
kpa-clawbot
2026-06-22 14:42:53 +00:00
committed by Kpa-clawbot
parent eea6b4dc5b
commit c4e26487f1
6 changed files with 224 additions and 32 deletions
+15
View File
@@ -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
+4
View File
@@ -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
+76 -16
View File
@@ -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,
}
+7
View File
@@ -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.",
+103 -15
View File
@@ -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))
}
+19 -1
View File
@@ -474,9 +474,27 @@
if (totalScore <= 0) {
return '<div class="text-muted" style="padding:20px">No relay activity observed in this window (all packets direct).</div>';
}
// 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 =
'<div class="dumbbell-preset text-muted" style="font-size:11px;padding:0 4px 6px 4px">' +
'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)' +
'</div>';
}
// Layout: per row → label | track 0..100% | values
var palette = ['#ef4444','#f59e0b','#22c55e','#3b82f6','#8b5cf6','#ec4899','#14b8a6','#64748b','#f97316','#06b6d4','#84cc16'];
var html = '<div class="dumbbell-chart" style="display:flex;flex-direction:column;gap:8px;padding:8px 4px">';
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 += '<div class="dumbbell-row" title="' + esc(tip) + '" style="display:grid;grid-template-columns:80px 1fr 180px;align-items:center;gap:10px;font-size:12px">' +
'<div class="dumbbell-label" style="font-weight:600;color:var(--text)">' + esc(name) + '</div>' +
'<div class="dumbbell-track" style="position:relative;height:18px;background:var(--bg-elev,rgba(127,127,127,0.12));border-radius:9px">' +