Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1023,8 +1023,8 @@ type AnalyticsConfig struct {
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
// LoRaPreset is the assumed PHY preset used by the relay-airtime-share
// metric to compute true Time-on-Air (issue #1768). Defaults to the
// EU MeshCore deployment: 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5.
// freq is informational only and surfaces in the analytics caption.
// MeshCore deployment: BW 62.5 kHz / SF 7 / CR 4/5.
// freq is informational only and is not displayed or used in ToA.
LoRaPreset *LoRaPresetConfig `json:"loraPreset,omitempty"`
}

Expand Down
9 changes: 4 additions & 5 deletions cmd/server/relay_airtime_share.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,21 @@ import (
//
// The PHY preset is config-driven (analytics.loraPreset in
// config.example.json); defaults match the actual deployment
// preset 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5, with the
// preset BW 62.5 kHz / SF 7 / CR 4/5, with the
// SF-dependent preamble pulled from internal/lora.PreambleForSF.
//
// Aggregated by payload_type. Originator TX is deliberately excluded — a
// never-relayed direct message scores 0, which is the correct framing for a
// "relay amplification" metric. In-memory only; no SQL, no new index.

// defaultLoRaPreset is the canonical fallback when config is absent.
// Matches the reporter's `get radio` output `869.6179809, 62.5, 8, 5`.
// Frequency does not affect ToA; it is kept only as an optional config field.
func defaultLoRaPreset() lora.Preset {
return lora.Preset{
FreqHz: 869.6e6,
BWkHz: 62.5,
SF: 8,
SF: 7,
CR: 5,
Preamble: lora.PreambleForSF(8),
Preamble: lora.PreambleForSF(7),
}
}

Expand Down
24 changes: 12 additions & 12 deletions cmd/server/relay_airtime_share_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
if !ok {
t.Fatalf("result['preset'] missing or wrong type: %T", result["preset"])
}
if preset.SF == 0 || preset.BWkHz == 0 || preset.CR == 0 || preset.Preamble == 0 || preset.FreqHz == 0 {
if preset.SF == 0 || preset.BWkHz == 0 || preset.CR == 0 || preset.Preamble == 0 {
t.Errorf("result['preset'] has zero fields: %+v", preset)
}

Expand Down Expand Up @@ -160,18 +160,18 @@ func TestRelayAirtimeShare_ADVERTvsACKDivergence(t *testing.T) {
// 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,
// default US preset (BW 62.5 kHz / SF 7 / 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 %
// T_sym = 128 / 62500 = 2.048 ms
// ADVERT (PL=200): symbols = 36.25 + (8 + ceil((1600-28+44)/28)*5)
// = 36.25 + (8 + 58*5) = 334.25
// ACK (PL=10): symbols = 36.25 + (8 + ceil((80-28+44)/28)*5)
// = 36.25 + (8 + 4*5) = 64.25
// ADVERT share = 334.25 / (334.25 + 64.25) = 0.83877 → 83.88 %
//
// 83 % vs 95 % is the whole point: small frames are no longer crushed
// against large ones because the additive preamble + fixed-overhead
Expand Down Expand Up @@ -214,11 +214,11 @@ func TestRelayAirtimeShare_ToAReplacesByteProxy(t *testing.T) {
// Sanity guards on the independent calculation. Tolerances ±0.05 pp
// around the AN1200.13 hand-computed values keep this honest if the
// upstream lora package ever drifts.
if math.Abs(wantAdvert-83.4754) > 0.05 {
t.Fatalf("independent ADVERT ToA share drifted: got %.4f want ~83.4754", wantAdvert)
if math.Abs(wantAdvert-83.8770) > 0.05 {
t.Fatalf("independent ADVERT ToA share drifted: got %.4f want ~83.8770", wantAdvert)
}
if math.Abs(wantAck-16.5246) > 0.05 {
t.Fatalf("independent ACK ToA share drifted: got %.4f want ~16.5246", wantAck)
if math.Abs(wantAck-16.1230) > 0.05 {
t.Fatalf("independent ACK ToA share drifted: got %.4f want ~16.1230", wantAck)
}
if math.Abs(advertPct-wantAdvert) > 0.2 {
t.Errorf("ADVERT airtime_pct = %.4f, want %.4f (true ToA)", advertPct, wantAdvert)
Expand Down
5 changes: 2 additions & 3 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -425,11 +425,10 @@
"nodesClockSkew": 300
},
"loraPreset": {
"freq": 869600000,
"bw": 62.5,
"sf": 8,
"sf": 7,
"cr": 5,
"_comment_": "Issue #1768. LoRa PHY preset assumed by the Relay Airtime Share metric to compute true Time-on-Air. Share numbers are only meaningful relative to one preset, so operators MUST set this to match their mesh — freq is informational (surfaces in the chart caption) and does not affect ToA; bw is bandwidth in kHz (e.g. 62.5, 125, 250); sf is spreading factor (6..12); cr is the denominator of the 4/N coding rate (5 ⇒ 4/5 … 8 ⇒ 4/8). Defaults reproduce the typical EU MeshCore deployment 869.6 MHz / BW 62.5 kHz / SF 8 / CR 4/5. CRC=1, IH=0, DE (T_sym ≥ 16 ms), and preamble (32 for SF≤8, 16 otherwise, per firmware preambleLengthForSF) are firmware-fixed and intentionally not exposed as config."
"_comment_": "Issue #1768. LoRa PHY preset assumed by the Relay Airtime Share metric to compute true Time-on-Air. Share numbers are only meaningful relative to one preset, so operators MUST set this to match their mesh — freq is optional, informational only, and neither displayed nor used in ToA; bw is bandwidth in kHz (e.g. 62.5, 125, 250); sf is spreading factor (6..12); cr is the denominator of the 4/N coding rate (5 ⇒ 4/5 … 8 ⇒ 4/8). Defaults: BW 62.5 kHz / SF 7 / CR 4/5. CRC=1, IH=0, DE (T_sym ≥ 16 ms), and preamble (32 for SF≤8, 16 otherwise, per firmware preambleLengthForSF) are firmware-fixed and intentionally not exposed as config."
}
},
"_comment_analytics": "Issue #1240 + #1256 + #1265. Each analytics endpoint (topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew) is recomputed in the background on the configured interval and served from an atomic-pointer cache. Reads never block on compute. Default 300s (5 min) per endpoint reflects the operator principle: serving slightly stale data quickly beats real-time data slowly. Lower values = fresher data at higher CPU cost. Only the default query (no region/window) is precomputed; region- and window-filtered requests fall back to the legacy on-request compute + 60s TTL cache.",
Expand Down
3 changes: 1 addition & 2 deletions public/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,13 +521,11 @@
var preset = data && data.preset;
var presetCaption = '';
if (preset && typeof preset === 'object') {
var freqMHz = Number(preset.freq_hz || 0) / 1e6;
var bwKhz = Number(preset.bw_khz || 0);
var bwStr = bwKhz ? bwKhz.toFixed(1).replace(/\.0$/, '') : '0';
presetCaption =
'<div class="dumbbell-preset text-muted" style="font-size:11px;padding:0 4px 6px 4px">' +
'Assumed LoRa preset: ' +
(freqMHz ? esc(freqMHz.toFixed(3)) + ' MHz / ' : '') +
'BW ' + esc(bwStr) + ' kHz / ' +
'SF ' + esc(String(Number(preset.sf || 0))) + ' / ' +
'CR 4/' + esc(String(Number(preset.cr || 0))) +
Expand Down Expand Up @@ -3175,6 +3173,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet
window._analyticsRetransmissionBucketFor = retransmissionBucketFor;
window._analyticsRenderRetransmissionChart = renderRetransmissionChart;
window._analyticsScopeAdvertsByRoleHtml = scopeAdvertsByRoleHtml;
window._analyticsRenderRelayAirtimeDumbbell = renderRelayAirtimeDumbbell;
}

// ─── Neighbor Graph Tab ─────────────────────────────────────────────────────
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test-frontend-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -6937,6 +6937,31 @@ console.log('\n=== map.js: hash size fallback ===');
});
}

// ===== analytics.js: Relay Airtime Share preset caption (#189) =====
console.log('\n=== analytics.js: renderRelayAirtimeDumbbell preset caption ===');
{
const ctx = makeSandbox();
loadInCtx(ctx, 'public/roles.js');
loadInCtx(ctx, 'public/app.js');
try { loadInCtx(ctx, 'public/analytics.js'); } catch (e) { /* IIFE side-effects ok */ }
const render = ctx.window._analyticsRenderRelayAirtimeDumbbell;
test('renderRelayAirtimeDumbbell is exposed', () => assert.ok(render));
if (render) {
const data = {
total_score: 10,
rows: [{ payload_type: 'ADVERT', count: 1, count_pct: 50, airtime_pct: 50, score: 5 }],
preset: { freq_hz: 869600000, bw_khz: 62.5, sf: 7, cr: 5, preamble: 32 },
};
test('caption shows BW / SF / CR but never the frequency (#189)', () => {
const html = render(data);
assert.ok(html.includes('BW 62.5 kHz'), 'BW missing');
assert.ok(html.includes('SF 7'), 'SF missing');
assert.ok(html.includes('CR 4/5'), 'CR missing');
assert.ok(!/MHz/.test(html), 'frequency must not be displayed: ' + html);
});
}
}

// ===== SUMMARY =====
Promise.allSettled(pendingTests).then(() => {
console.log(`\n${'═'.repeat(40)}`);
Expand Down
Loading