Compare commits

...

1 Commits

Author SHA1 Message Date
kpa-clawbot 8da570620b 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 00:32:18 +00:00
4 changed files with 220 additions and 84 deletions
+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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/meshcore-analyzer/lora
go 1.22
+25
View File
@@ -0,0 +1,25 @@
// Package lora implements closed-form LoRa Time-on-Air calculations.
//
// Issue #1768 — see toa.go for the final implementation. This file
// holds the public API stub used by the failing red commit; the green
// commit replaces TimeOnAir's body with the closed-form expression.
package lora
import "time"
// Preset captures the LoRa PHY parameters needed to compute ToA.
type Preset struct {
FreqHz float64
BWkHz float64
SF int
CR int
Preamble int
}
// PreambleForSF returns MeshCore's SF-dependent preamble length.
// Stub: returns 0 in the red commit.
func PreambleForSF(sf int) int { return 0 }
// TimeOnAir returns the LoRa time-on-air for a payload.
// Stub: returns 0 in the red commit.
func TimeOnAir(payloadBytes int, preset Preset) time.Duration { return 0 }
+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 = 16.384 ms ≥ 16ms → DE = 1; preamble = 16.
// PL=8: num = 64 - 44 + 44 = 76 ... actually:
// num = 8*8 - 4*11 + 28 + 16 = 64 - 44 + 44 = 64
// den = 4*(11 - 2) = 36; ceil(64/36) = 2
// symbols_payload = 8 + 2*5 = 18
// preamble_symbols = 16 + 4.25 = 20.25
// total = 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")
}
}