diff --git a/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index 8b6744f4..d594d123 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -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) } } 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..948339b5 --- /dev/null +++ b/internal/lora/toa.go @@ -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 } diff --git a/internal/lora/toa_test.go b/internal/lora/toa_test.go new file mode 100644 index 00000000..77a0d740 --- /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 = 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") + } +}