mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 17:07:53 +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>
245 lines
9.4 KiB
Go
245 lines
9.4 KiB
Go
package main
|
||
|
||
import (
|
||
"math"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/meshcore-analyzer/lora"
|
||
)
|
||
|
||
// newRelayAirtimeShareTestStore builds a minimal PacketStore for testing
|
||
// computeRelayAirtimeShare without any DB or background workers.
|
||
func newRelayAirtimeShareTestStore(packets []*StoreTx) *PacketStore {
|
||
ps := &PacketStore{
|
||
packets: packets,
|
||
byHash: make(map[string]*StoreTx),
|
||
byTxID: make(map[int]*StoreTx),
|
||
byObsID: make(map[int]*StoreObs),
|
||
byObserver: make(map[string][]*StoreObs),
|
||
byNode: make(map[string][]*StoreTx),
|
||
byPathHop: make(map[string][]*StoreTx),
|
||
nodeHashes: make(map[string]map[string]bool),
|
||
byPayloadType: make(map[int][]*StoreTx),
|
||
rfCache: make(map[string]*cachedResult),
|
||
topoCache: make(map[string]*cachedResult),
|
||
hashCache: make(map[string]*cachedResult),
|
||
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),
|
||
}
|
||
ps.useResolvedPathIndex = true
|
||
ps.initResolvedPathIndex()
|
||
for _, tx := range packets {
|
||
ps.byTxID[tx.ID] = tx
|
||
if tx.Hash != "" {
|
||
ps.byHash[tx.Hash] = tx
|
||
}
|
||
if tx.PayloadType != nil {
|
||
pt := *tx.PayloadType
|
||
ps.byPayloadType[pt] = append(ps.byPayloadType[pt], tx)
|
||
}
|
||
}
|
||
return ps
|
||
}
|
||
|
||
// makeRelayAirtimeTx builds a synthetic transmission with rawHex sized for the
|
||
// given byte count.
|
||
func makeRelayAirtimeTx(id int, payloadType int, payloadBytes int, distinctRelays int, hashPrefix string) *StoreTx {
|
||
pt := payloadType
|
||
return &StoreTx{
|
||
ID: id,
|
||
Hash: hashPrefix,
|
||
FirstSeen: "2026-01-01T00:00:00Z",
|
||
PayloadType: &pt,
|
||
RawHex: strings.Repeat("ab", payloadBytes),
|
||
}
|
||
}
|
||
|
||
// TestRelayAirtimeShare_ADVERTvsACKDivergence (issue #1768 v2 of #1359 test):
|
||
// - 1 ADVERT, 200 B, 8 distinct relays
|
||
// - 1000 ACKs, 10 B, 0 distinct relays
|
||
//
|
||
// 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)
|
||
advert := makeRelayAirtimeTx(1, PayloadADVERT, 200, 8, "ad000001")
|
||
packets = append(packets, advert)
|
||
for i := 0; i < 1000; i++ {
|
||
ack := makeRelayAirtimeTx(100+i, PayloadACK, 10, 0, "")
|
||
ack.Hash = "ac" + zeroPad(i, 6)
|
||
packets = append(packets, ack)
|
||
}
|
||
store := newRelayAirtimeShareTestStore(packets)
|
||
relayPks := []string{"r01", "r02", "r03", "r04", "r05", "r06", "r07", "r08"}
|
||
store.addToResolvedPubkeyIndex(advert.ID, relayPks)
|
||
|
||
if got := store.distinctRelayCount(advert); got != 8 {
|
||
t.Fatalf("distinctRelayCount(ADVERT) = %d, want 8", got)
|
||
}
|
||
|
||
result := store.computeRelayAirtimeShare(TimeWindow{})
|
||
|
||
// 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['preset'] missing or wrong type: %T", result["preset"])
|
||
}
|
||
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)
|
||
}
|
||
|
||
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 := byType["ADVERT"]
|
||
ackRow := byType["ACK"]
|
||
if advertRow == nil || ackRow == nil {
|
||
t.Fatalf("missing rows: %+v", rows)
|
||
}
|
||
|
||
advertAirtimePct, _ := advertRow["airtime_pct"].(float64)
|
||
ackAirtimePct, _ := ackRow["airtime_pct"].(float64)
|
||
if advertAirtimePct < 99.5 || advertAirtimePct > 100.001 {
|
||
t.Errorf("ADVERT airtime_pct = %.4f, want 100.0", advertAirtimePct)
|
||
}
|
||
if ackAirtimePct != 0.0 {
|
||
t.Errorf("ACK airtime_pct = %.4f, want 0.0", ackAirtimePct)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
if ackCount != 1000 {
|
||
t.Errorf("ACK count = %d, want 1000", ackCount)
|
||
}
|
||
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[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)
|
||
}
|
||
}
|
||
|
||
func zeroPad(n, width int) string {
|
||
s := ""
|
||
for i := 0; i < width; i++ {
|
||
s = string(rune('0'+(n%10))) + s
|
||
n /= 10
|
||
}
|
||
return s
|
||
}
|