Compare commits

..

4 Commits

Author SHA1 Message Date
Kpa-clawbot fac87625ab test(#1768): clarify SF11/BW125 derivation comment 2026-06-22 15:00:41 +00:00
Kpa-clawbot 03b249ae1d chore(#1768): cross-stack: justified — score formula caption update
The relay-airtime score formula change in cmd/server/relay_airtime_share.go
requires the matching banner/tooltip update in public/analytics.js so
operators can interpret the assumed PHY block. Marker for preflight
check-branch-clean gate.
2026-06-22 14:44:53 +00:00
kpa-clawbot c4e26487f1 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
2026-06-22 14:42:53 +00:00
kpa-clawbot eea6b4dc5b test(#1768): red — LoRa ToA + preset-in-response acceptance tests
Adds internal/lora package skeleton (stubbed TimeOnAir returns 0,
PreambleForSF returns 0) plus failing tests that assert the closed-form
LoRa Time-on-Air values for the deployment's actual preset
(869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, preamble 32 from firmware
preambleLengthForSF).

Rewrites cmd/server/relay_airtime_share_test.go acceptance suite to:
  - require a 'preset' block on the JSON response (caption needs it)
  - assert ADVERT airtime share follows true ToA (~83.48 %) on a
    single-relay ADVERT vs single-relay ACK, NOT the byte proxy's
    95.24 % — negative gate guarantees the implementation actually
    swaps the score formula, not just renames it.

Tests fail with assertion errors (not build errors). Green commit
in the next push wires the formula and the config preset.
2026-06-22 14:42:53 +00:00
9 changed files with 429 additions and 101 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,
}
+98 -84
View File
@@ -1,6 +1,7 @@
package main
import (
"math"
"strings"
"testing"
)
@@ -24,10 +25,10 @@ func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore {
collisionCache: make(map[string]*cachedResult),
chanCache: make(map[string]*cachedResult),
distCache: make(map[string]*cachedResult),
subpathCache: make(map[string]*cachedResult),
spIndex: make(map[string]int),
spTxIndex: make(map[string][]*StoreTx),
advertPubkeys: make(map[string]int),
subpathCache: make(map[string]*cachedResult),
spIndex: make(map[string]int),
spTxIndex: make(map[string][]*StoreTx),
advertPubkeys: make(map[string]int),
}
ps.useResolvedPathIndex = true
ps.initResolvedPathIndex()
@@ -45,100 +46,74 @@ func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore {
}
// makeRelayAirtimeTx builds a synthetic transmission with rawHex sized for the
// given byte count and registers `distinctRelays` synthetic resolved-path
// pubkeys via the resolved-pubkey reverse index — same source that
// distinctRelayCount must read from.
// given byte count.
func makeRelayAirtimeTx(id int, payloadType int, payloadBytes int, distinctRelays int, hashPrefix string) *StoreTx {
pt := payloadType
tx := &StoreTx{
return &StoreTx{
ID: id,
Hash: hashPrefix,
FirstSeen: "2026-01-01T00:00:00Z",
PayloadType: &pt,
RawHex: strings.Repeat("ab", payloadBytes), // 2 hex chars per byte
RawHex: strings.Repeat("ab", payloadBytes),
}
return tx
}
// TestRelayAirtimeShare_ADVERTvsACKDivergence is the locked acceptance test
// from issue #1359:
// - 1 ADVERT, 200 B, 8 distinct relays → score = 200 * 8 = 1600
// - 1000 ACKs, 10 B each, 0 relays → score = 0
// TestRelayAirtimeShare_ADVERTvsACKDivergence (issue #1768 v2 of #1359 test):
// - 1 ADVERT, 200 B, 8 distinct relays
// - 1000 ACKs, 10 B, 0 distinct relays
//
// Count distribution: ACK 1000/1001 = 99.90%, ADVERT 0.10%.
// Airtime distribution: ADVERT 1600/1600 = 100%, ACK 0%.
//
// This is the headline divergence the dumbbell chart must visualize.
// The ACK score is still 0 (no relays); ADVERT carries 100 % of airtime.
// The headline divergence (ADVERT ranks #1 by airtime despite tiny count
// share) survives the switch from byte-proxy to true ToA. Adds a check
// that the JSON response surfaces the active preset (issue #1768 requires
// the preset in the caption, so it must reach the client).
func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
packets := make([]*StoreTx, 0, 1001)
// 1 ADVERT with 200 bytes payload + 8 distinct relays
advert := makeRelayAirtimeTx(1, PayloadADVERT, 200, 8, "ad000001")
packets = append(packets, advert)
// 1000 ACKs with 10 bytes payload + 0 relays
for i := 0; i < 1000; i++ {
ack := makeRelayAirtimeTx(100+i, PayloadACK, 10, 0, "")
// Give each a unique hash so dedup doesn't collapse them.
ack.Hash = "ac" + zeroPad(i, 6)
packets = append(packets, ack)
}
store := newRelayAirtimeShareTestStore(packets)
// Wire up the 8 distinct relay pubkeys for the ADVERT through the
// resolved-pubkey reverse index — the helper distinctRelayCount must
// read from this source (union across all observations of tx.ID).
relayPks := []string{
"relay01", "relay02", "relay03", "relay04",
"relay05", "relay06", "relay07", "relay08",
}
relayPks := []string{"r01", "r02", "r03", "r04", "r05", "r06", "r07", "r08"}
store.addToResolvedPubkeyIndex(advert.ID, relayPks)
// Sanity check the helper directly.
if got := store.distinctRelayCount(advert); got != 8 {
t.Fatalf("distinctRelayCount(ADVERT) = %d, want 8", got)
}
if got := store.distinctRelayCount(packets[1]); got != 0 {
t.Fatalf("distinctRelayCount(ACK) = %d, want 0", got)
}
result := store.computeRelayAirtimeShare(TimeWindow{})
rows, ok := result["rows"].([]map[string]interface{})
// New: preset must be in the response so the client can render the
// caption per issue #1768 (caller cannot interpret "Airtime %"
// without knowing the assumed SF/BW/CR).
preset, ok := result["preset"].(map[string]interface{})
if !ok {
t.Fatalf("result['rows'] missing or wrong type: %T", result["rows"])
t.Fatalf("result['preset'] missing or wrong type: %T", result["preset"])
}
if len(rows) < 2 {
t.Fatalf("expected at least 2 rows (ADVERT, ACK), got %d: %+v", len(rows), rows)
for _, key := range []string{"freq_hz", "bw_khz", "sf", "cr", "preamble"} {
if _, ok := preset[key]; !ok {
t.Errorf("result['preset'] missing %q: %+v", key, preset)
}
}
// Index by payload_type name.
rows, ok := result["rows"].([]map[string]interface{})
if !ok || len(rows) < 2 {
t.Fatalf("unexpected rows: %T %+v", result["rows"], result["rows"])
}
byType := make(map[string]map[string]interface{})
for _, r := range rows {
name, _ := r["payload_type"].(string)
byType[name] = r
}
advertRow, hasAdvert := byType["ADVERT"]
ackRow, hasACK := byType["ACK"]
if !hasAdvert {
t.Fatalf("rows missing ADVERT bucket: %+v", rows)
}
if !hasACK {
t.Fatalf("rows missing ACK bucket: %+v", rows)
advertRow := byType["ADVERT"]
ackRow := byType["ACK"]
if advertRow == nil || ackRow == nil {
t.Fatalf("missing rows: %+v", rows)
}
// Count percentages: ACK should be ~99.9%, ADVERT ~0.1%.
ackCountPct, _ := ackRow["count_pct"].(float64)
advertCountPct, _ := advertRow["count_pct"].(float64)
if !(ackCountPct > 99.0 && ackCountPct < 100.0) {
t.Errorf("ACK count_pct = %.4f, want ~99.9", ackCountPct)
}
if !(advertCountPct < 1.0 && advertCountPct > 0.0) {
t.Errorf("ADVERT count_pct = %.4f, want ~0.1", advertCountPct)
}
// Airtime percentages: ADVERT should be 100%, ACK 0%.
advertAirtimePct, _ := advertRow["airtime_pct"].(float64)
ackAirtimePct, _ := ackRow["airtime_pct"].(float64)
if advertAirtimePct < 99.5 || advertAirtimePct > 100.001 {
@@ -147,31 +122,70 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
if ackAirtimePct != 0.0 {
t.Errorf("ACK airtime_pct = %.4f, want 0.0", ackAirtimePct)
}
// Raw score check: ADVERT = 200 * 8 = 1600.
advertScore, _ := advertRow["score"].(int)
if advertScore != 1600 {
t.Errorf("ADVERT score = %d, want 1600 (200B × 8 relays)", advertScore)
}
ackScore, _ := ackRow["score"].(int)
if ackScore != 0 {
t.Errorf("ACK score = %d, want 0 (no relays)", ackScore)
}
// Count integer check.
advertCount, _ := advertRow["count"].(int)
if advertCount != 1 {
t.Errorf("ADVERT count = %d, want 1", advertCount)
}
ackCount, _ := ackRow["count"].(int)
if ackCount != 1000 {
t.Errorf("ACK count = %d, want 1000", ackCount)
}
// The divergence: ADVERT should rank #1 by airtime even though its
// count share is the smallest. This is the whole point of the chart.
if rows[0]["payload_type"] != "ADVERT" {
t.Errorf("rows must be sorted by airtime_pct desc; rows[0] payload_type = %v, want ADVERT", rows[0]["payload_type"])
t.Errorf("rows[0] = %v, want ADVERT (sort by airtime desc)", rows[0]["payload_type"])
}
}
// TestRelayAirtimeShare_ToAReplacesByteProxy is the issue #1768 acceptance
// gate: airtime_pct must follow true LoRa Time-on-Air, NOT bytes.
//
// Setup: 1 ADVERT (200 B, 1 relay) and 1 ACK (10 B, 1 relay) with the
// default EU preset (869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5,
// preamble 32 per firmware preambleLengthForSF).
//
// Old byte-proxy would give ADVERT 200/(200+10) = 95.24 %.
// True ToA per #1768 closed form:
//
// T_sym = 256 / 62500 = 4.096 ms
// ADVERT (PL=200): symbols = 36.25 + (8 + ceil((1600-32+44)/32)*5)
// = 36.25 + (8 + 51*5) = 299.25 → 1225.728 ms
// ACK (PL=10): symbols = 36.25 + (8 + ceil((80-32+44)/32)*5)
// = 36.25 + (8 + 3*5) = 59.25 → 242.688 ms
// ADVERT share = 1225.728 / (1225.728 + 242.688) = 0.83476 → 83.48 %
//
// 83 % vs 95 % is the whole point: small frames are no longer crushed
// against large ones because the additive preamble + fixed-overhead
// intercept finally enters the score. A test that still passes against
// the byte proxy would fail to gate the regression — we explicitly
// assert away from 95 %.
func TestRelayAirtimeShare_ToAReplacesByteProxy(t *testing.T) {
advert := makeRelayAirtimeTx(1, PayloadADVERT, 200, 1, "ad000001")
ack := makeRelayAirtimeTx(2, PayloadACK, 10, 1, "ac000001")
store := newRelayAirtimeShareTestStore([]*StoreTx{advert, ack})
store.addToResolvedPubkeyIndex(advert.ID, []string{"relay-A"})
store.addToResolvedPubkeyIndex(ack.ID, []string{"relay-B"})
result := store.computeRelayAirtimeShare(TimeWindow{})
rows, ok := result["rows"].([]map[string]interface{})
if !ok || len(rows) != 2 {
t.Fatalf("unexpected rows: %T %+v", result["rows"], result["rows"])
}
byType := make(map[string]map[string]interface{})
for _, r := range rows {
name, _ := r["payload_type"].(string)
byType[name] = r
}
advertPct, _ := byType["ADVERT"]["airtime_pct"].(float64)
ackPct, _ := byType["ACK"]["airtime_pct"].(float64)
// True ToA acceptance bands. Wide enough (±0.2 pp) to absorb any
// trivial rounding without admitting the byte proxy (which would
// give ~95.24 %, 4.76 %).
const wantAdvert = 83.4754
const wantAck = 16.5246
if math.Abs(advertPct-wantAdvert) > 0.2 {
t.Errorf("ADVERT airtime_pct = %.4f, want %.4f (true ToA)", advertPct, wantAdvert)
}
if math.Abs(ackPct-wantAck) > 0.2 {
t.Errorf("ACK airtime_pct = %.4f, want %.4f (true ToA)", ackPct, wantAck)
}
// Negative gate: the OLD byte-proxy answer must NOT come back.
// 95 % vs 5 % means we're still on the bytes×relays code path.
if advertPct > 90.0 {
t.Errorf("ADVERT airtime_pct = %.4f looks like byte proxy (≈95.24); ToA path missing", advertPct)
}
}
+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.",
+3
View File
@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/lora
go 1.22
+113
View File
@@ -0,0 +1,113 @@
// Package lora implements closed-form LoRa Time-on-Air calculations.
//
// 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 (
"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 // 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:
// 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 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))
}
+94
View File
@@ -0,0 +1,94 @@
package lora
import (
"math"
"testing"
"time"
)
// Reference values cross-checked against the closed-form computation
// shown in issue #1768 and against RadioLib calculateTimeOnAir():
// freq=869.6 MHz, BW=62.5 kHz, SF=8, CR=4/5, preamble=32 (SF<=8)
// T_sym = 256/62500 = 4.096 ms
// preamble_symbols = 32 + 4.25 = 36.25
//
// PL=8: num = 64 - 32 + 28 + 16 = 76; den = 4*(8 - 0) = 32 (DE=0 since T_sym<16ms)
// ceil(76/32) = 3 → symbols_payload = 8 + 3*5 = 23
// total = (36.25 + 23) * 4.096 = 242.688 ms
// PL=16: total = 283.648 ms
// PL=32: total = 365.568 ms
// PL=64: total = 529.408 ms
//
// These match the table in the issue #1768 body to the microsecond.
func TestTimeOnAir_DefaultEUPreset(t *testing.T) {
preset := Preset{
FreqHz: 869.6e6,
BWkHz: 62.5,
SF: 8,
CR: 5,
Preamble: PreambleForSF(8), // 32
}
cases := []struct {
pl int
wantMs float64
}{
{8, 242.688},
{16, 283.648},
{32, 365.568},
{64, 529.408},
}
for _, c := range cases {
got := TimeOnAir(c.pl, preset)
gotMs := float64(got) / float64(time.Millisecond)
if math.Abs(gotMs-c.wantMs) > 0.01 {
t.Errorf("TimeOnAir(%d B) = %.3f ms, want %.3f ms", c.pl, gotMs, c.wantMs)
}
}
}
// SF11/BW125 → T_sym = 2048/125000 = 16.384 ms ≥ 16ms → DE = 1; preamble = 16.
// PL=8:
// num = 8*8 - 4*11 + 28 + 16 = 64 - 44 + 44 = 64
// den = 4*(11 - 2*1) = 36; ceil(64/36) = 2
// symbols_payload = 8 + 2*5 = 18
// preamble_symbols = 16 + 4.25 = 20.25
// total = (20.25 + 18) * 16.384 = 38.25 * 16.384 ≈ 626.688 ms
func TestTimeOnAir_DERangeSF11(t *testing.T) {
preset := Preset{
BWkHz: 125,
SF: 11,
CR: 5,
Preamble: PreambleForSF(11),
}
got := TimeOnAir(8, preset)
wantMs := 626.688
gotMs := float64(got) / float64(time.Millisecond)
if math.Abs(gotMs-wantMs) > 0.05 {
t.Errorf("TimeOnAir(SF11/BW125, PL=8) = %.3f ms, want ~%.3f ms", gotMs, wantMs)
}
}
func TestTimeOnAir_InvalidPresetReturnsZero(t *testing.T) {
cases := []Preset{
{BWkHz: 125, SF: 5, CR: 5}, // SF too low
{BWkHz: 125, SF: 13, CR: 5}, // SF too high
{BWkHz: 0, SF: 8, CR: 5}, // BW zero
{BWkHz: 125, SF: 8, CR: 4}, // CR too low
{BWkHz: 125, SF: 8, CR: 9}, // CR too high
}
for _, p := range cases {
if got := TimeOnAir(16, p); got != 0 {
t.Errorf("TimeOnAir(invalid preset %+v) = %v, want 0", p, got)
}
}
}
func TestPreambleForSF(t *testing.T) {
if PreambleForSF(7) != 32 || PreambleForSF(8) != 32 {
t.Errorf("PreambleForSF(<=8) must be 32 (firmware preambleLengthForSF)")
}
if PreambleForSF(9) != 16 || PreambleForSF(12) != 16 {
t.Errorf("PreambleForSF(>8) must be 16")
}
}
+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">' +