diff --git a/cmd/server/config.go b/cmd/server/config.go index dbfee265..0f6428f6 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -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"` } diff --git a/cmd/server/relay_airtime_share.go b/cmd/server/relay_airtime_share.go index 3b634566..35f0ee41 100644 --- a/cmd/server/relay_airtime_share.go +++ b/cmd/server/relay_airtime_share.go @@ -22,7 +22,7 @@ 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 @@ -30,14 +30,13 @@ import ( // "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), } } diff --git a/cmd/server/relay_airtime_share_test.go b/cmd/server/relay_airtime_share_test.go index b08732ec..78f19a17 100644 --- a/cmd/server/relay_airtime_share_test.go +++ b/cmd/server/relay_airtime_share_test.go @@ -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) } @@ -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 @@ -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) diff --git a/config.example.json b/config.example.json index 7977e2fd..d65263b8 100644 --- a/config.example.json +++ b/config.example.json @@ -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.", diff --git a/public/analytics.js b/public/analytics.js index 62c13565..d2a47e4c 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -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 = '
' + '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))) + @@ -3175,6 +3173,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopDistanceRet window._analyticsRetransmissionBucketFor = retransmissionBucketFor; window._analyticsRenderRetransmissionChart = renderRetransmissionChart; window._analyticsScopeAdvertsByRoleHtml = scopeAdvertsByRoleHtml; + window._analyticsRenderRelayAirtimeDumbbell = renderRelayAirtimeDumbbell; } // ─── Neighbor Graph Tab ───────────────────────────────────────────────────── diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index ad54cb4f..00812d53 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -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)}`);