mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 06:37:23 +00:00
Rebase of #1881 by @SaarMesh-Bot onto current master. Their three commits are preserved, two of them cherry-picked with authorship intact; the sweep itself had to be regenerated. Opened as a new PR rather than force-pushing their branch. Closes #1881 once merged. Addresses parts 1 and 3 of #1859; part 2 landed as #1937. ## Why regenerated rather than merged The sweep in #1881 was cut on 2026-09-02 07:13 and roughly forty PRs landed after it, so it went `CONFLICTING/DIRTY`. Re-running `gofmt` on current master is cheaper and less error-prone than resolving 72 conflicts that are all whitespace. The drift it fixes also grew in the meantime: 66 files now, against 72 then, but spread differently. ## The three commits 1. **`style(#1859)`** — `gofmt -w` across the 14 modules. 66 files. 2. **`test(#1859)`** — @SaarMesh-Bot's fix for the one `go vet` copylocks finding, `cmd/ingestor/coverage_boost_test.go`: the range variable copied a `Config` embedding `sync.Once`. Cherry-picked unchanged. 3. **`ci(#1859)`** — @SaarMesh-Bot's CI step that fails on gofmt drift or vet findings, plus `.git-blame-ignore-revs`. Cherry-picked with one change, noted in the commit message: the ignore file pointed at `04bc80ee`, the sweep commit on their branch, which does not exist on this base and would make `git blame --ignore-revs-file` error. Repointed at `d3a02599`, the sweep here. ## Verification The claim "formatting only" is checked twice rather than asserted: - Every changed file is byte-identical to `gofmt(previous content)`. 0 of 66 deviate. - With line comments and all whitespace stripped, 0 of 66 files differ, so no code outside comments changed. 14 of the 66 also show doc-comment reflow. Since Go 1.19 `gofmt` re-indents indented comment blocks to tabs and inserts a blank comment line before them; the behavior matrix above `resolveHopWithContext` in `cmd/ingestor/path_resolver.go` is a clear example. That is gofmt's own output, not an edit, but it is worth naming because it makes the diff look larger than "whitespace" suggests. The gate was run locally exactly as the workflow runs it: `gofmt` clean, and `go vet` clean in all 14 modules, including `cmd/ingestor` which is what commit 2 fixes. Suites: `cmd/server` ok (80.7s), `internal/packetpath` ok (2.3s), `cmd/ingestor` passes except `TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which fails identically on bare master with "A required privilege is not held by the client" (Windows symlink privilege on my host, not code). ## Sequencing This should go last in the queue. The sweep touches 66 files, so merging it before the remaining open Go PRs gives each of them a conflict about nothing but formatting. After it lands the gate is active, and any PR with drift fails CI until it runs `gofmt -w`. Excluded from the sweep: the misnamed `Dockerfile.go`, which is a Dockerfile that gofmt cannot parse (the workflow excludes it too), and `docs/DEPLOYMENT.md`, which a case-insensitive filesystem surfaces as a spurious modification against `docs/deployment.md` and is unrelated. --------- Co-authored-by: SaarMesh-Bot <300107934+SaarMesh-Bot@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
119 lines
3.9 KiB
Go
119 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))
|
||
}
|