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>
95 lines
2.6 KiB
Go
95 lines
2.6 KiB
Go
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 = 2048/125000 = 16.384 ms ≥ 16ms → DE = 1; preamble = 16.
|
|
// PL=8:
|
|
// num = 8*8 - 4*11 + 28 + 16 = 64 - 44 + 44 = 64
|
|
// den = 4*(11 - 2*1) = 36; ceil(64/36) = 2
|
|
// symbols_payload = 8 + 2*5 = 18
|
|
// preamble_symbols = 16 + 4.25 = 20.25
|
|
// total = (20.25 + 18) * 16.384 = 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")
|
|
}
|
|
}
|