Files
meshcore-analyzer/cmd/server/relay_airtime_share.go
T
57956712e7 fix(#1768): Relay Airtime Share uses LoRa Time-on-Air (preamble-aware) — partial fix (#1776)
Partial fix for #1768 — Relay Airtime Share now uses closed-form LoRa
Time-on-Air instead of a payload-bytes-only proxy, removing the ~3-4×
bias against small frames (preamble + fixed-symbol intercept).

cross-stack: justified — backend score formula needs a frontend caption
change (`public/analytics.js` dumbbell preset banner + tooltip) so
operators can interpret the assumed PHY block. Both move together or the
metric is misleading.

## Red commit

`8da57062` — failing test asserts ToA-based score (~83.48 % ADVERT share
on the locked acceptance fixture) instead of the byte proxy's 95.24 %.
`internal/lora.TimeOnAir` was a zero-returning stub at the red commit;
tests failed with assertion errors, not build errors.

## Green commit

`dd402edd` — implements `lora.TimeOnAir` (Semtech AN1200.13 / SX126x
§6.1.4 closed form, cross-checked against RadioLib), wires `score =
TimeOnAir(payloadBytes, preset) × distinctRelays` in
`cmd/server/relay_airtime_share.go`, surfaces the preset in the JSON
response and analytics caption.

## Config (per AGENTS Config Documentation Rule)

New keys under existing `analytics` block:

```json
"loraPreset": { "freq": 869600000, "bw": 62.5, "sf": 8, "cr": 5 }
```

Defaults match the deployment's actual `get radio` (869.6 MHz / BW 62.5
kHz / SF 8 / CR 4/5). `CRC=1`, `IH=0`, `DE = (T_sym ≥ 16 ms)`, and the
SF-dependent preamble (32 for SF≤8 else 16, per firmware
`preambleLengthForSF` / MeshCore PR #1954) are firmware-fixed constants
in `internal/lora/toa.go` and intentionally NOT surfaced as config (per
re-triage).

## Scope

In-scope files (6):

- `internal/lora/toa.go` (new package — closed-form ToA)
- `internal/lora/toa_test.go` (table-driven preset tests)
- `cmd/server/relay_airtime_share.go` (wire ToA into score)
- `cmd/server/relay_airtime_share_test.go` (recomputed expected values)
- `cmd/server/config.go` + `config.example.json` (preset config keys)
- `public/analytics.js` (preset caption on dumbbell chart + tooltip)

Plus `cmd/server/go.mod` (replace directive for the new internal
module).

## Deferred to v2 (separate issues per re-triage)

- Per-observation SF/BW + radio-settings-aware dedup (blocked: ingestor
stores SNR/RSSI only, no SF/BW on observations).
- CR-per-hop dual-point sensitivity band (CR scales only the payload
symbol term `(CR+4)`, not the preamble/header; second-order accuracy
gain).
- Cross-SF bridge accounting.

## Tests

```
cd internal/lora && go test ./...           → PASS
cd cmd/server && go test -run RelayAirtime  → PASS
```

## Preflight overrides

- `check-branch-clean` (cross-stack): justified above — score formula
change requires matching caption update; both files trace to the same
issue.

---------

Co-authored-by: kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: bot <bot@meshcore>
2026-06-22 10:07:49 -07:00

275 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"log"
"sort"
"time"
"github.com/meshcore-analyzer/lora"
)
// relay_airtime_share.go — issues #1359 + #1768
//
// Implements the "Relay Airtime Share" analytics metric:
// 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.
// 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.
//
// 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.
//
// Source: the resolved-pubkey reverse index — populated by
// indexResolvedPathHops / addToResolvedPubkeyIndex from every observation's
// resolved_path. Each entry is one distinct pubkey hash for THIS tx (the
// indexer dedups (hash, txID) pairs before appending).
//
// Caller MUST hold s.mu at least RLock.
func (s *PacketStore) distinctRelayCount(tx *StoreTx) int {
if tx == nil || !s.useResolvedPathIndex {
return 0
}
return len(s.resolvedPubkeyReverse[tx.ID])
}
// computeRelayAirtimeShare aggregates relay-airtime-share per payload_type.
//
// Returns:
//
// {
// "rows": [{payload_type, type, count, count_pct, score, airtime_pct}, ...] sorted by airtime_pct desc,
// "total_count": int,
// "total_score": int64 (nanoseconds of LoRa Time-on-Air × repeater-count, summed across packets),
// "window": window label,
// "cached": false (overwritten by cached wrapper),
// }
func (s *PacketStore) computeRelayAirtimeShare(window TimeWindow) map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
ptNames := payloadTypeNames
preset := s.resolveLoRaPreset()
type bucket struct {
count 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
var totalScore int64
for _, tx := range s.packets {
if tx == nil || tx.PayloadType == nil {
continue
}
if !window.Includes(tx.FirstSeen) {
continue
}
// Dedup per-hash: each distinct packet counted once. ACKs in the
// test fixture have unique hashes so this only collapses true
// re-observations of the same packet.
if tx.Hash != "" {
if seenHash[tx.Hash] {
continue
}
seenHash[tx.Hash] = true
}
pt := *tx.PayloadType
b := buckets[pt]
if b == nil {
b = &bucket{}
buckets[pt] = b
}
b.count++
totalCount++
// 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)
toa := lora.TimeOnAir(payloadBytes, preset)
score := int64(toa) * int64(relays)
b.score += score
totalScore += score
}
rows := make([]map[string]interface{}, 0, len(buckets))
for pt, b := range buckets {
name := ptNames[pt]
if name == "" {
name = "UNK"
}
var countPct, airtimePct float64
if totalCount > 0 {
countPct = float64(b.count) / float64(totalCount) * 100.0
}
if totalScore > 0 {
airtimePct = float64(b.score) / float64(totalScore) * 100.0
}
rows = append(rows, map[string]interface{}{
"payload_type": name,
"type": pt,
"count": b.count,
"count_pct": countPct,
"score": b.score,
"airtime_pct": airtimePct,
})
}
// Sort descending by airtime_pct; tiebreak count desc, then name asc
// for deterministic ordering.
sort.SliceStable(rows, func(i, j int) bool {
ai, _ := rows[i]["airtime_pct"].(float64)
aj, _ := rows[j]["airtime_pct"].(float64)
if ai != aj {
return ai > aj
}
ci, _ := rows[i]["count"].(int)
cj, _ := rows[j]["count"].(int)
if ci != cj {
return ci > cj
}
ni, _ := rows[i]["payload_type"].(string)
nj, _ := rows[j]["payload_type"].(string)
return ni < nj
})
label := ""
if !window.IsZero() {
label = window.Label
}
return map[string]interface{}{
"rows": rows,
"total_count": totalCount,
"total_score": totalScore,
"preset": presetJSON(preset),
"window": label,
"cached": false,
}
}
// GetRelayAirtimeShareWithWindow is the cached wrapper around
// computeRelayAirtimeShare. Reuses the existing rfCache + rfCacheTTL pool
// (shared with RF / topology / distance analytics — no new cache layer per
// #1359 spec).
func (s *PacketStore) GetRelayAirtimeShareWithWindow(window TimeWindow) map[string]interface{} {
cacheKey := "relay-airtime-share|"
if !window.IsZero() {
cacheKey += window.CacheKey()
}
s.cacheMu.Lock()
if cached, ok := s.rfCache[cacheKey]; ok && time.Now().Before(cached.expiresAt) {
s.cacheHits++
s.cacheMu.Unlock()
// Shallow copy with cached=true so the JSON client can tell.
m := cached.data
out := make(map[string]interface{}, len(m)+1)
for k, v := range m {
out[k] = v
}
out["cached"] = true
return out
}
s.cacheMisses++
s.cacheMu.Unlock()
result := s.computeRelayAirtimeShare(window)
s.cacheMu.Lock()
s.rfCache[cacheKey] = &cachedResult{data: result, expiresAt: time.Now().Add(s.rfCacheTTL)}
s.cacheMu.Unlock()
return result
}