mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 19:27:58 +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>
118 lines
3.9 KiB
Go
118 lines
3.9 KiB
Go
// Package lora implements closed-form LoRa Time-on-Air calculations.
|
||
//
|
||
// Issue #1768 — replaces the bytes-only proxy in
|
||
// cmd/server/relay_airtime_share.go with a true ToA estimate so the
|
||
// "Airtime %" headline metric is no longer biased ~3-4× against small
|
||
// frames by the preamble + fixed-symbol intercept.
|
||
//
|
||
// Reference: Semtech AN1200.13 / SX126x datasheet v1.1 §6.1.4.
|
||
// Cross-checked against RadioLib calculateTimeOnAir() (issue #1768
|
||
// discussion); MeshCore-specific constants:
|
||
//
|
||
// - CRC = 1 (setCRC(1) in MeshCore drivers)
|
||
// - IH = 0 (explicit-header default, never overridden)
|
||
// - DE = 1 iff T_sym ≥ 16 ms (per SX126x_commands.cpp:224)
|
||
//
|
||
// Preamble follows MeshCore's preambleLengthForSF (firmware
|
||
// RadioLibWrappers.h:47, MeshCore PR #1954): 32 symbols for SF≤8, 16
|
||
// otherwise. Callers should pass PreambleForSF(sf) when modeling the
|
||
// MeshCore default; if Preset.Preamble is zero TimeOnAir falls back to
|
||
// the LoRa-protocol default 8.
|
||
package lora
|
||
|
||
import (
|
||
"math"
|
||
"time"
|
||
)
|
||
|
||
// Preset captures the LoRa PHY parameters needed to compute ToA.
|
||
// FreqHz is informational only (recorded for the analytics caption)
|
||
// and does not enter the ToA formula.
|
||
type Preset struct {
|
||
FreqHz float64 // e.g. 869.6e6 — informational only
|
||
BWkHz float64 // bandwidth in kHz (e.g. 62.5, 125, 250)
|
||
SF int // spreading factor (6..12)
|
||
CR int // coding-rate denominator suffix: 5 ⇒ 4/5 … 8 ⇒ 4/8
|
||
Preamble int // preamble symbols; 0 ⇒ LoRa default 8
|
||
}
|
||
|
||
// PreambleForSF returns MeshCore's SF-dependent preamble length:
|
||
// 32 symbols for SF≤8, 16 otherwise. Mirrors firmware
|
||
// preambleLengthForSF (RadioLibWrappers.h:47, PR #1954).
|
||
func PreambleForSF(sf int) int {
|
||
if sf <= 8 {
|
||
return 32
|
||
}
|
||
return 16
|
||
}
|
||
|
||
// TimeOnAir returns the LoRa time-on-air for a payload of payloadBytes
|
||
// transmitted with the given preset.
|
||
//
|
||
// Closed form (Semtech AN1200.13 / SX126x §6.1.4) with MeshCore
|
||
// constants CRC=1, IH=0:
|
||
//
|
||
// T_sym = 2^SF / BW_Hz
|
||
// DE = 1 if T_sym ≥ 16 ms else 0
|
||
// n_payload = 8 + max(ceil((8·PL − 4·SF + 28 + 16·CRC − 20·IH) /
|
||
// (4·(SF − 2·DE))) · coding_coeff, 0)
|
||
//
|
||
// coding_coeff = preset.CR directly (we encode the denominator 5..8
|
||
// so coefficient = denominator; Semtech notation uses CR ∈ 1..4 with
|
||
// coefficient = CR+4, which is the same arithmetic).
|
||
// n_preamble = preamble + 4.25
|
||
// ToA = (n_preamble + n_payload) · T_sym
|
||
//
|
||
// Invalid presets (SF outside 6..12, BW≤0, CR outside 5..8, negative
|
||
// payload) return 0 so callers can guard cheaply on a zero result.
|
||
func TimeOnAir(payloadBytes int, preset Preset) time.Duration {
|
||
if payloadBytes < 0 {
|
||
return 0
|
||
}
|
||
if preset.SF < 6 || preset.SF > 12 {
|
||
return 0
|
||
}
|
||
if preset.BWkHz <= 0 {
|
||
return 0
|
||
}
|
||
if preset.CR < 5 || preset.CR > 8 {
|
||
return 0
|
||
}
|
||
preamble := preset.Preamble
|
||
if preamble <= 0 {
|
||
preamble = 8
|
||
}
|
||
|
||
bwHz := preset.BWkHz * 1000.0
|
||
tSym := math.Exp2(float64(preset.SF)) / bwHz // seconds per symbol
|
||
de := 0
|
||
if tSym*1000.0 >= 16.0 {
|
||
de = 1
|
||
}
|
||
|
||
// CRC=1, IH=0 → constant +28 + 16*1 − 20*0 = +44.
|
||
num := 8*payloadBytes - 4*preset.SF + 28 + 16
|
||
den := 4 * (preset.SF - 2*de)
|
||
if den <= 0 {
|
||
return 0
|
||
}
|
||
var symbolsPayload float64
|
||
if num <= 0 {
|
||
// AN1200.13 §4.1.1.6: the inner ceil() argument can go negative
|
||
// for very small payloads (the −4·SF + 28 + 16·CRC terms dominate
|
||
// 8·PL). The reference formula clamps that ceil() to ≥ 0, so
|
||
// symbolsPayload collapses to the fixed 8-symbol header term
|
||
// when num ≤ 0. This is a max(., 0) clamp on the payload-symbol
|
||
// count, NOT a separate header-floor rule.
|
||
symbolsPayload = 8
|
||
} else {
|
||
ceilTerm := math.Ceil(float64(num) / float64(den))
|
||
symbolsPayload = 8 + ceilTerm*float64(preset.CR)
|
||
}
|
||
|
||
preambleSymbols := float64(preamble) + 4.25
|
||
totalSymbols := preambleSymbols + symbolsPayload
|
||
secs := totalSymbols * tSym
|
||
return time.Duration(secs * float64(time.Second))
|
||
}
|