diff --git a/Dockerfile b/Dockerfile index cf8dd3b2..bfb8fd00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,7 @@ COPY internal/dbschema/ ../../internal/dbschema/ COPY internal/prunequeue/ ../../internal/prunequeue/ COPY internal/perfio/ ../../internal/perfio/ COPY internal/mbcapqueue/ ../../internal/mbcapqueue/ +COPY internal/lora/ ../../internal/lora/ RUN go mod download COPY cmd/server/ ./ RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ 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..9eb85d9b 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -1,27 +1,109 @@ package main import ( + "log" "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. +// "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. // -// 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). +// Out-of-range SF / CR are NOT silently clamped on a per-field basis +// (the prior behaviour produced a confusing hybrid preset, partially +// operator-supplied and partially defaulted). Instead we keep the +// default for the offending field AND log a single WARN at resolve time +// naming the field plus the actual vs. effective value. There is no +// startup-time analytics-config validation gate today, so refusal-to- +// start is not an option — the WARN is the gate. Zero / unset fields +// fall back silently as before (the operator opted out of overriding +// that param). +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 != 0 { + if cfg.SF >= 6 && cfg.SF <= 12 { + p.SF = cfg.SF + p.Preamble = lora.PreambleForSF(cfg.SF) + } else { + log.Printf("[analytics.loraPreset] WARN: sf=%d out of range [6,12], using default sf=%d", cfg.SF, p.SF) + } + } + if cfg.CR != 0 { + if cfg.CR >= 5 && cfg.CR <= 8 { + p.CR = cfg.CR + } else { + log.Printf("[analytics.loraPreset] WARN: cr=%d out of range [5,8], using default cr=%d", cfg.CR, p.CR) + } + } + return p +} + +// presetResponse 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. +type presetResponse struct { + FreqHz float64 `json:"freq_hz"` + BWkHz float64 `json:"bw_khz"` + SF int `json:"sf"` + CR int `json:"cr"` + Preamble int `json:"preamble"` +} + +func presetJSON(p lora.Preset) presetResponse { + return presetResponse{ + FreqHz: p.FreqHz, + BWkHz: 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. @@ -46,7 +128,7 @@ func (s *PacketStore) distinctRelayCount(tx *StoreTx) int { // { // "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc, // "total_count": int, -// "total_score": int, +// "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets), // "window": window label, // "cached": false (overwritten by cached wrapper), // } @@ -55,15 +137,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 +173,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 +233,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/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index 8b6744f4..988b906f 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -1,8 +1,11 @@ package main import ( + "math" "strings" "testing" + + "github.com/meshcore-analyzer/lora" ) // newRelayAirtimeShareTestStore builds a minimal PacketStore for testing @@ -24,10 +27,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 +48,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). The shape is a typed + // presetResponse struct (PR #1776 review round-1: no + // map[string]interface{} in API surfaces). + preset, ok := result["preset"].(presetResponse) 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) + if preset.SF == 0 || preset.BWkHz == 0 || preset.CR == 0 || preset.Preamble == 0 || preset.FreqHz == 0 { + t.Errorf("result['preset'] has zero fields: %+v", 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 { @@ -148,30 +125,112 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) { 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. + // Count-side assertions prove the divergence story: ACK dominates + // by raw packet count (≈99.9%) but ADVERT dominates by airtime + // (100%). Without these the test only proves half the chart. advertCount, _ := advertRow["count"].(int) + ackCount, _ := ackRow["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. + advertCountPct, _ := advertRow["count_pct"].(float64) + ackCountPct, _ := ackRow["count_pct"].(float64) + // 1 of 1001 = 0.0999 %; 1000 of 1001 = 99.9001 %. + if math.Abs(advertCountPct-(1.0/1001.0*100.0)) > 0.001 { + t.Errorf("ADVERT count_pct = %.4f, want %.4f", advertCountPct, 1.0/1001.0*100.0) + } + if math.Abs(ackCountPct-(1000.0/1001.0*100.0)) > 0.001 { + t.Errorf("ACK count_pct = %.4f, want %.4f", ackCountPct, 1000.0/1001.0*100.0) + } + // The headline: ACK is ≫ ADVERT by count but 0 by airtime. That + // inversion is the whole reason the dumbbell exists. + if !(ackCountPct > advertCountPct && advertAirtimePct > ackAirtimePct) { + t.Errorf("expected count/airtime inversion: ackCountPct=%.4f advertCountPct=%.4f advertAirtimePct=%.4f ackAirtimePct=%.4f", + ackCountPct, advertCountPct, advertAirtimePct, ackAirtimePct) + } 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 derived INDEPENDENTLY from the + // implementation under test, by computing each row's ToA via + // lora.TimeOnAir on the same preset and forming the share by hand. + // (The prior `wantAck = 16.5246` constant was just `100 - wantAdvert` + // — a tautology that would silently pass if shares stopped summing + // to 100. Driving from lora.TimeOnAir means a regression there + // surfaces here.) + preset := defaultLoRaPreset() + advertToA := float64(lora.TimeOnAir(200, preset)) + ackToA := float64(lora.TimeOnAir(10, preset)) + wantAdvert := advertToA / (advertToA + ackToA) * 100.0 + wantAck := ackToA / (advertToA + ackToA) * 100.0 + // Sanity guards on the independent calculation. Tolerances ±0.05 pp + // around the AN1200.13 hand-computed values keep this honest if the + // upstream lora package ever drifts. + if math.Abs(wantAdvert-83.4754) > 0.05 { + t.Fatalf("independent ADVERT ToA share drifted: got %.4f want ~83.4754", wantAdvert) + } + if math.Abs(wantAck-16.5246) > 0.05 { + t.Fatalf("independent ACK ToA share drifted: got %.4f want ~16.5246", wantAck) + } + 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) } } 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/go.mod b/internal/lora/go.mod new file mode 100644 index 00000000..0781c978 --- /dev/null +++ b/internal/lora/go.mod @@ -0,0 +1,3 @@ +module github.com/meshcore-analyzer/lora + +go 1.22 diff --git a/internal/lora/toa.go b/internal/lora/toa.go new file mode 100644 index 00000000..f76aba38 --- /dev/null +++ b/internal/lora/toa.go @@ -0,0 +1,117 @@ +// 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 { + // AN1200.13 §4.1.1.6: the inner ceil() argument can go negative + // for very small payloads (the −4·SF + 28 + 16·CRC terms dominate + // 8·PL). The reference formula clamps that ceil() to ≥ 0, so + // symbolsPayload collapses to the fixed 8-symbol header term + // when num ≤ 0. This is a max(., 0) clamp on the payload-symbol + // count, NOT a separate header-floor rule. + 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/internal/lora/toa_test.go b/internal/lora/toa_test.go new file mode 100644 index 00000000..399c7471 --- /dev/null +++ b/internal/lora/toa_test.go @@ -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") + } +} diff --git a/public/analytics.js b/public/analytics.js index fa4d0b9f..e7b0ad52 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -406,7 +406,7 @@

Relay Airtime Share

-

Score = payload bytes × distinct repeaters that forwarded the packet. Counts relay re-transmissions; originator TX excluded. Not comparable across meshes.

+

Score = LoRa Time-on-Air × distinct repeaters that forwarded the packet (issue #1768). Counts relay re-transmissions; originator TX excluded. Not comparable across meshes — see preset caption below.

${renderRelayAirtimeDumbbell(d.airtimeData)}
@@ -474,9 +474,33 @@ 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. All fields are run through esc() defensively + // (preset.sf/cr/preamble/bw_khz/freq_hz arrive from the server JSON, + // which the operator can configure — never inject untrusted text raw). + // BW formatted consistent with home.js / customize-v2.js presets + // (e.g. `62.5 kHz`, `125 kHz`) — strip trailing `.0` for integer kHz. + var preset = data && data.preset; + var presetCaption = ''; + if (preset && typeof preset === 'object') { + var freqMHz = Number(preset.freq_hz || 0) / 1e6; + var bwKhz = Number(preset.bw_khz || 0); + var bwStr = bwKhz ? bwKhz.toFixed(1).replace(/\.0$/, '') : '0'; + presetCaption = + '
' + + 'Assumed LoRa preset: ' + + (freqMHz ? esc(freqMHz.toFixed(3)) + ' MHz / ' : '') + + 'BW ' + esc(bwStr) + ' kHz / ' + + 'SF ' + esc(String(Number(preset.sf || 0))) + ' / ' + + 'CR 4/' + esc(String(Number(preset.cr || 0))) + + ' (preamble ' + esc(String(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); @@ -487,11 +511,18 @@ var loPct = Math.min(cpct, apct); var hiPct = Math.max(cpct, apct); // Tooltip per row — payload_type, count %, count N, airtime %, raw score, caveat. + // Score is summed (ToA in ns) × distinct repeaters across packets of + // this type; converted to ms for human-readable display per #1768 + // round-1 review. + var scoreMs = score / 1e6; + var scoreStr = scoreMs >= 1000 + ? (scoreMs / 1000).toFixed(2) + ' s' + : scoreMs.toFixed(2) + ' ms'; var tip = name + '\n' + 'Count: ' + cnt.toLocaleString() + ' (' + cpct.toFixed(2) + '%)\n' + - 'Airtime: ' + apct.toFixed(2) + '% (score ' + score.toLocaleString() + ')\n' + - 'Score = bytes × distinct repeaters. Within-mesh only.'; + 'Airtime: ' + apct.toFixed(2) + '% (score ' + scoreStr + ' · airtime × repeaters)\n' + + 'Score = LoRa Time-on-Air × distinct repeaters. Within-mesh only.'; html += '
' + '
' + esc(name) + '
' + '
' +