mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 14:12:32 +00:00
## Problem The "Top 20 Longest Hops" RF analytics card shows the same repeater pair filling most slots because the query sorts raw hop records by distance with no pair deduplication. A single long link observed 12+ times dominates the leaderboard. ## Fix Dedupe by unordered `(pk1, pk2)` pair. Per pair, keep the max-distance record and compute reliability metrics: | Column | Description | |--------|-------------| | **Obs** | Total observations of this link | | **Best SNR** | Maximum SNR seen (dB) | | **Median SNR** | Median SNR across all observations (dB) | Tooltip on each row shows the timestamp of the best observation. ### Before | # | From | To | Distance | Type | SNR | Packet | |---|------|----|----------|------|-----|--------| | 1 | NodeX | NodeY | 200 mi | R↔R | 5 dB | abc… | | 2 | NodeX | NodeY | 199 mi | R↔R | 6 dB | def… | | 3 | NodeX | NodeY | 198 mi | R↔R | 4 dB | ghi… | ### After | # | From | To | Distance | Type | Obs | Best SNR | Median SNR | Packet | |---|------|----|----------|------|-----|----------|------------|--------| | 1 | NodeX | NodeY | 200 mi | R↔R | 12 | 8.0 dB | 5.2 dB | abc… | | 2 | NodeA | NodeB | 150 mi | C↔R | 3 | 6.5 dB | 6.5 dB | jkl… | ## Changes - **`cmd/server/store.go`**: Group `filteredHops` by unordered pair key, accumulate obs count / best SNR / median SNR per group, sort by max distance, take top 20 - **`cmd/server/types.go`**: Update `DistanceHop` struct — replace `SNR` with `BestSnr`, `MedianSnr`, add `ObsCount` - **`public/analytics.js`**: Replace single SNR column with Obs, Best SNR, Median SNR; add row tooltip with best observation timestamp - **`cmd/server/store_tophops_test.go`**: 3 unit tests — basic dedupe, reverse-pair merge, nil SNR edge case ## Test Coverage - `TestDedupeTopHopsByPair`: 5 records on pair (A,B) + 1 on (C,D) → 2 results, correct obsCount/dist/bestSnr/medianSnr - `TestDedupeTopHopsReversePairMerges`: (B,A) and (A,B) merge into one entry - `TestDedupeTopHopsNilSNR`: all-nil SNR records → bestSnr and medianSnr both nil - Existing `TestAnalyticsRFEndpoint` and `TestAnalyticsRFWithRegion` still pass Closes #847 --------- Co-authored-by: you <you@example.com>
This commit is contained in:
1 parent
7c01a97178
commit
a371d35bfd
5 files changed
+212
-23
No files matched your search
+77
-15
@@ -247,6 +247,82 @@ type distHopRecord struct {
|
||||
tx *StoreTx
|
||||
}
|
||||
|
||||
// dedupeHopsByPair groups hops by unordered node-pair, keeps the max-distance
|
||||
// record per pair, and computes obsCount / bestSnr / medianSnr. limit caps the
|
||||
// number of returned entries (sorted by distance descending).
|
||||
func dedupeHopsByPair(hops []distHopRecord, limit int) []map[string]interface{} {
|
||||
type pairAgg struct {
|
||||
best *distHopRecord
|
||||
obsCount int
|
||||
maxSNR *float64
|
||||
snrs []float64
|
||||
}
|
||||
pairMap := make(map[string]*pairAgg)
|
||||
for i := range hops {
|
||||
h := &hops[i]
|
||||
pk1, pk2 := h.FromPk, h.ToPk
|
||||
if pk1 > pk2 {
|
||||
pk1, pk2 = pk2, pk1
|
||||
}
|
||||
key := pk1 + "|" + pk2
|
||||
agg, ok := pairMap[key]
|
||||
if !ok {
|
||||
agg = &pairAgg{}
|
||||
pairMap[key] = agg
|
||||
}
|
||||
agg.obsCount++
|
||||
if h.SNR != nil {
|
||||
agg.snrs = append(agg.snrs, *h.SNR)
|
||||
if agg.maxSNR == nil || *h.SNR > *agg.maxSNR {
|
||||
v := *h.SNR
|
||||
agg.maxSNR = &v
|
||||
}
|
||||
}
|
||||
if agg.best == nil || h.Dist > agg.best.Dist {
|
||||
agg.best = h
|
||||
}
|
||||
}
|
||||
type pairEntry struct {
|
||||
key string
|
||||
agg *pairAgg
|
||||
}
|
||||
pairs := make([]pairEntry, 0, len(pairMap))
|
||||
for k, v := range pairMap {
|
||||
pairs = append(pairs, pairEntry{k, v})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool { return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist })
|
||||
result := make([]map[string]interface{}, 0, min(limit, len(pairs)))
|
||||
for i, pe := range pairs {
|
||||
if i >= limit {
|
||||
break
|
||||
}
|
||||
h := pe.agg.best
|
||||
var medianSNR *float64
|
||||
if len(pe.agg.snrs) > 0 {
|
||||
sorted := make([]float64, len(pe.agg.snrs))
|
||||
copy(sorted, pe.agg.snrs)
|
||||
sort.Float64s(sorted)
|
||||
mid := len(sorted) / 2
|
||||
if len(sorted)%2 == 0 {
|
||||
v := (sorted[mid-1] + sorted[mid]) / 2
|
||||
medianSNR = &v
|
||||
} else {
|
||||
v := sorted[mid]
|
||||
medianSNR = &v
|
||||
}
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"fromName": h.FromName, "fromPk": h.FromPk,
|
||||
"toName": h.ToName, "toPk": h.ToPk,
|
||||
"dist": h.Dist, "type": h.Type,
|
||||
"bestSnr": floatPtrOrNil(pe.agg.maxSNR), "medianSnr": floatPtrOrNil(medianSNR),
|
||||
"obsCount": pe.agg.obsCount,
|
||||
"hash": h.Hash, "timestamp": h.Timestamp,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type distPathRecord struct {
|
||||
Hash string
|
||||
TotalDist float64
|
||||
@@ -5114,21 +5190,7 @@ func (s *PacketStore) computeAnalyticsDistance(region string) map[string]interfa
|
||||
}
|
||||
}
|
||||
|
||||
// Sort and pick top hops
|
||||
sort.Slice(filteredHops, func(i, j int) bool { return filteredHops[i].Dist > filteredHops[j].Dist })
|
||||
topHops := make([]map[string]interface{}, 0)
|
||||
for i := range filteredHops {
|
||||
if i >= 50 {
|
||||
break
|
||||
}
|
||||
h := &filteredHops[i]
|
||||
topHops = append(topHops, map[string]interface{}{
|
||||
"fromName": h.FromName, "fromPk": h.FromPk,
|
||||
"toName": h.ToName, "toPk": h.ToPk,
|
||||
"dist": h.Dist, "type": h.Type,
|
||||
"snr": floatPtrOrNil(h.SNR), "hash": h.Hash, "timestamp": h.Timestamp,
|
||||
})
|
||||
}
|
||||
topHops := dedupeHopsByPair(filteredHops, 20)
|
||||
|
||||
// Sort and pick top paths
|
||||
sort.Slice(filteredPaths, func(i, j int) bool { return filteredPaths[i].TotalDist > filteredPaths[j].TotalDist })
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
|
||||
func TestDedupeTopHopsByPair(t *testing.T) {
|
||||
hops := []distHopRecord{
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: f64(5.0), Hash: "h1", Timestamp: "t1"},
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: f64(8.0), Hash: "h2", Timestamp: "t2"},
|
||||
{FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 80, Type: "R↔R", SNR: f64(3.0), Hash: "h3", Timestamp: "t3"},
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 70, Type: "R↔R", SNR: f64(6.0), Hash: "h4", Timestamp: "t4"},
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 60, Type: "R↔R", SNR: f64(4.0), Hash: "h5", Timestamp: "t5"},
|
||||
{FromPk: "CCC", ToPk: "DDD", FromName: "C", ToName: "D", Dist: 50, Type: "C↔R", SNR: f64(7.0), Hash: "h6", Timestamp: "t6"},
|
||||
}
|
||||
|
||||
result := dedupeHopsByPair(hops, 20)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(result))
|
||||
}
|
||||
|
||||
// First entry: A↔B pair, max distance = 100, obsCount = 5
|
||||
ab := result[0]
|
||||
if ab["dist"].(float64) != 100 {
|
||||
t.Errorf("expected dist 100, got %v", ab["dist"])
|
||||
}
|
||||
if ab["obsCount"].(int) != 5 {
|
||||
t.Errorf("expected obsCount 5, got %v", ab["obsCount"])
|
||||
}
|
||||
if ab["hash"].(string) != "h1" {
|
||||
t.Errorf("expected hash h1 (from max-dist record), got %v", ab["hash"])
|
||||
}
|
||||
if ab["bestSnr"].(float64) != 8.0 {
|
||||
t.Errorf("expected bestSnr 8.0, got %v", ab["bestSnr"])
|
||||
}
|
||||
// medianSnr of [3,4,5,6,8] = 5.0
|
||||
if ab["medianSnr"].(float64) != 5.0 {
|
||||
t.Errorf("expected medianSnr 5.0, got %v", ab["medianSnr"])
|
||||
}
|
||||
|
||||
// Second entry: C↔D pair
|
||||
cd := result[1]
|
||||
if cd["dist"].(float64) != 50 {
|
||||
t.Errorf("expected dist 50, got %v", cd["dist"])
|
||||
}
|
||||
if cd["obsCount"].(int) != 1 {
|
||||
t.Errorf("expected obsCount 1, got %v", cd["obsCount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeTopHopsReversePairMerges(t *testing.T) {
|
||||
hops := []distHopRecord{
|
||||
{FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 50, Type: "R↔R", Hash: "h1"},
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 80, Type: "R↔R", Hash: "h2"},
|
||||
}
|
||||
result := dedupeHopsByPair(hops, 20)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(result))
|
||||
}
|
||||
if result[0]["obsCount"].(int) != 2 {
|
||||
t.Errorf("expected obsCount 2, got %v", result[0]["obsCount"])
|
||||
}
|
||||
if result[0]["dist"].(float64) != 80 {
|
||||
t.Errorf("expected dist 80, got %v", result[0]["dist"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeTopHopsNilSNR(t *testing.T) {
|
||||
hops := []distHopRecord{
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: nil, Hash: "h1"},
|
||||
{FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: nil, Hash: "h2"},
|
||||
}
|
||||
result := dedupeHopsByPair(hops, 20)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(result))
|
||||
}
|
||||
if result[0]["bestSnr"] != nil {
|
||||
t.Errorf("expected bestSnr nil, got %v", result[0]["bestSnr"])
|
||||
}
|
||||
if result[0]["medianSnr"] != nil {
|
||||
t.Errorf("expected medianSnr nil, got %v", result[0]["medianSnr"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeTopHopsLimit(t *testing.T) {
|
||||
// Generate 25 unique pairs, verify limit=20 caps output
|
||||
hops := make([]distHopRecord, 25)
|
||||
for i := range hops {
|
||||
hops[i] = distHopRecord{
|
||||
FromPk: "A", ToPk: string(rune('a' + i)),
|
||||
Dist: float64(i), Type: "R↔R", Hash: "h",
|
||||
}
|
||||
}
|
||||
result := dedupeHopsByPair(hops, 20)
|
||||
if len(result) != 20 {
|
||||
t.Errorf("expected 20 entries, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeTopHopsEvenMedian(t *testing.T) {
|
||||
// Even count: median = avg of two middle values
|
||||
hops := []distHopRecord{
|
||||
{FromPk: "A", ToPk: "B", Dist: 10, Type: "R↔R", SNR: f64(2.0), Hash: "h1"},
|
||||
{FromPk: "A", ToPk: "B", Dist: 20, Type: "R↔R", SNR: f64(4.0), Hash: "h2"},
|
||||
{FromPk: "A", ToPk: "B", Dist: 30, Type: "R↔R", SNR: f64(6.0), Hash: "h3"},
|
||||
{FromPk: "A", ToPk: "B", Dist: 40, Type: "R↔R", SNR: f64(8.0), Hash: "h4"},
|
||||
}
|
||||
result := dedupeHopsByPair(hops, 20)
|
||||
// sorted SNR: [2,4,6,8], median = (4+6)/2 = 5.0
|
||||
if result[0]["medianSnr"].(float64) != 5.0 {
|
||||
t.Errorf("expected medianSnr 5.0, got %v", result[0]["medianSnr"])
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -42,14 +42,20 @@
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"snr": {
|
||||
"type": "number"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
},
|
||||
"bestSnr": {
|
||||
"type": "number"
|
||||
},
|
||||
"medianSnr": {
|
||||
"type": "number"
|
||||
},
|
||||
"obsCount": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1580,4 +1586,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -678,7 +678,9 @@ type DistanceHop struct {
|
||||
ToPk string `json:"toPk"`
|
||||
Dist float64 `json:"dist"`
|
||||
Type string `json:"type"`
|
||||
SNR interface{} `json:"snr"`
|
||||
BestSnr interface{} `json:"bestSnr"`
|
||||
MedianSnr interface{} `json:"medianSnr"`
|
||||
ObsCount int `json:"obsCount"`
|
||||
Hash string `json:"hash"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
+6
-3
@@ -1937,15 +1937,18 @@
|
||||
}
|
||||
|
||||
// Top hops leaderboard
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
html += `<div class="analytics-section"><h3>🏆 Top 20 Longest Hops</h3><table class="data-table"><thead><tr><th scope="col">#</th><th scope="col">From</th><th scope="col">To</th><th scope="col">Distance (${distUnitLabel})</th><th scope="col">Type</th><th scope="col">Obs</th><th scope="col">Best SNR</th><th scope="col">Median SNR</th><th scope="col">Packet</th><th scope="col"></th></tr></thead><tbody>`;
|
||||
const top20 = data.topHops.slice(0, 20);
|
||||
top20.forEach((h, i) => {
|
||||
const fromLink = h.fromPk ? `<a href="#/nodes/${encodeURIComponent(h.fromPk)}" class="analytics-link">${esc(h.fromName)}</a>` : esc(h.fromName || '?');
|
||||
const toLink = h.toPk ? `<a href="#/nodes/${encodeURIComponent(h.toPk)}" class="analytics-link">${esc(h.toName)}</a>` : esc(h.toName || '?');
|
||||
const snr = h.snr != null ? h.snr + ' dB' : '<span class="text-muted">—</span>';
|
||||
const bestSnr = h.bestSnr != null ? Number(h.bestSnr).toFixed(1) + ' dB' : '<span class="text-muted">—</span>';
|
||||
const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : '<span class="text-muted">—</span>';
|
||||
const obs = h.obsCount != null ? h.obsCount : 1;
|
||||
const pktLink = h.hash ? `<a href="#/packet/${encodeURIComponent(h.hash)}" class="analytics-link mono" style="font-size:0.85em">${esc(h.hash.slice(0, 12))}…</a>` : '—';
|
||||
const mapBtn = h.fromPk && h.toPk ? `<button class="btn-icon dist-map-hop" data-from="${esc(h.fromPk)}" data-to="${esc(h.toPk)}" title="View on map">🗺️</button>` : '';
|
||||
html += `<tr><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${snr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : '';
|
||||
html += `<tr title="${esc(tsTitle)}"><td>${i+1}</td><td>${fromLink}</td><td>${toLink}</td><td><strong>${formatDistance(h.dist)}</strong></td><td>${esc(h.type)}</td><td>${obs}</td><td>${bestSnr}</td><td>${medianSnr}</td><td>${pktLink}</td><td>${mapBtn}</td></tr>`;
|
||||
});
|
||||
html += `</tbody></table></div>`;
|
||||
|
||||
|
||||
Reference in new issue
Block a user