Files
meshcore-analyzer/internal/lora/toa_test.go
T
57956712e7 fix(#1768): Relay Airtime Share uses LoRa Time-on-Air (preamble-aware) — partial fix (#1776)
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>
2026-06-22 10:07:49 -07:00

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")
}
}