mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:27:57 +00:00
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>
275 lines
8.2 KiB
Go
275 lines
8.2 KiB
Go
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
|
||
}
|