From 4969ac28b7c74acbf37794ed161fd0f486ce9980 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 4 Mar 2026 02:39:30 +0000 Subject: [PATCH] Live confirmed-link learning and real-time frontend updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Worker loads confirmed links (itm_viable + observed_count >= 5) when resolving relay hashes — prefers known neighbours over geographic proximity, creating a self-improving feedback loop as traffic accumulates - Visited-node set added to worker resolver (each node relays once per packet) - Frontend handles live link_update WebSocket messages: linkPairs and viablePairsArr updated in real-time without page refresh - MIN_LINK_OBSERVATIONS constant defined in both worker and frontend (5) - link_update added to WSMessageType union Co-Authored-By: Claude Sonnet 4.6 --- backend/src/types/index.ts | 2 +- frontend/src/App.tsx | 19 +++++++++++++ viewshed-worker/worker.py | 55 +++++++++++++++++++++++++++++++------- 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index d102c4a..18dd328 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -39,7 +39,7 @@ export interface Node { firmwareVersion?: string; } -export type WSMessageType = 'packet' | 'node_update' | 'node_upsert' | 'coverage_update' | 'initial_state'; +export type WSMessageType = 'packet' | 'node_update' | 'node_upsert' | 'coverage_update' | 'initial_state' | 'link_update'; export interface WSMessage { type: WSMessageType; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fe13fd..f6328d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -102,6 +102,7 @@ function resolvePathWaypoints( // ── Beta path helpers ───────────────────────────────────────────────────────── const MAX_BETA_HOPS = 15; +const MIN_LINK_OBSERVATIONS = 5; // must match backend db/index.ts /** Canonical lookup key for a link between two nodes (order-independent). */ function linkKey(a: string, b: string): string { @@ -452,6 +453,24 @@ export const App: React.FC = () => { handleNodeUpsert(msg.data as Partial & { node_id: string }); } else if (msg.type === 'coverage_update') { handleCoverageUpdate(msg.data as { node_id: string; geom: { type: string; coordinates: unknown } }); + } else if (msg.type === 'link_update') { + const d = msg.data as { + node_a_id: string; node_b_id: string; + observed_count: number; itm_viable: boolean | null; + }; + if (d.itm_viable && d.observed_count >= MIN_LINK_OBSERVATIONS) { + const key = linkKey(d.node_a_id, d.node_b_id); + setLinkPairs((prev) => { + if (prev.has(key)) return prev; + const next = new Set(prev); + next.add(key); + return next; + }); + setViablePairsArr((prev) => { + if (prev.some(([a, b]) => linkKey(a, b) === key)) return prev; + return [...prev, [d.node_a_id, d.node_b_id]]; + }); + } } }, [handleInitialState, handlePacket, handleNodeUpdate, handleNodeUpsert, handleCoverageUpdate]); diff --git a/viewshed-worker/worker.py b/viewshed-worker/worker.py index 5e35085..a55a51e 100644 --- a/viewshed-worker/worker.py +++ b/viewshed-worker/worker.py @@ -44,7 +44,8 @@ JOB_QUEUE = 'meshcore:viewshed_jobs' LINK_JOB_QUEUE = 'meshcore:link_jobs' LIVE_CHANNEL = 'meshcore:live' -ANTENNA_HEIGHT_M = 5 # observer height above ground (m) — fixed 5 m antenna +ANTENNA_HEIGHT_M = 5 # observer height above ground (m) — fixed 5 m antenna +MIN_LINK_OBSERVATIONS = 5 # must match backend db/index.ts MAX_RADIUS_M = 100_000 # absolute cap on viewshed radius (m) SIMPLIFY_DEG = 0.001 # Douglas-Peucker tolerance (~100 m) N_RAYS = 720 # number of radial rays cast from the observer @@ -468,7 +469,12 @@ def backfill_elevations(db): def process_link_job(db, r_client, job: dict): """Resolve relay path prefixes to known nodes (backwards from the receiver), - record observations in node_links, and compute RF path loss for new pairs.""" + record observations in node_links, and compute RF path loss for new pairs. + + Uses accumulated confirmed-link knowledge (node_links) to prefer known + neighbours over purely geographic proximity — the algorithm improves as + more packets are observed. + """ rx_node_id = job.get('rx_node_id') src_node_id = job.get('src_node_id') path_hashes = job.get('path_hashes', []) @@ -476,7 +482,7 @@ def process_link_job(db, r_client, job: dict): if not rx_node_id or not path_hashes: return - # Load all positioned nodes + # Load all positioned repeater nodes with db.cursor() as cur: cur.execute( 'SELECT node_id, lat, lon, elevation_m, name, role FROM nodes ' @@ -488,6 +494,21 @@ def process_link_job(db, r_client, job: dict): for row in cur.fetchall() } + # Load confirmed link pairs so we can prefer known neighbours when resolving + # relay hashes — forms the self-improving feedback loop. + with db.cursor() as cur: + cur.execute( + 'SELECT node_a_id, node_b_id FROM node_links ' + 'WHERE itm_viable = true AND observed_count >= %s', + (MIN_LINK_OBSERVATIONS,), + ) + confirmed_pairs: set[tuple[str, str]] = { + (min(a, b), max(a, b)) for a, b in cur.fetchall() + } + + def confirmed_link(a_id: str, b_id: str) -> bool: + return (min(a_id, b_id), max(a_id, b_id)) in confirmed_pairs + rx = all_nodes.get(rx_node_id) if not rx: return @@ -499,23 +520,39 @@ def process_link_job(db, r_client, job: dict): ((a['lon'] - b['lon']) * 111.32 * cos_m) ** 2 ) - # Resolve path working backwards from rx (known position anchor) - resolved: list[tuple[str, dict]] = [] # built in reverse, then flipped - prev = rx + # Resolve path working backwards from rx (known position anchor). + # Each node can only appear once — MeshCore nodes never relay the same + # packet twice. + resolved: list[tuple[str, dict]] = [] + prev_id = rx_node_id + prev = rx + visited = {rx_node_id} + for prefix in reversed(path_hashes): prefix = prefix[:2].upper() candidates = [ (nid, nd) for nid, nd in all_nodes.items() if nid.upper().startswith(prefix) + and nid not in visited and (nd['role'] is None or nd['role'] == 2) and nd['name'] and '🚫' not in nd['name'] ] if not candidates: continue - candidates.sort(key=lambda x: node_dist(x[1], prev)) - best_id, best = candidates[0] + + # Prefer confirmed neighbours of the previous node; fall back to closest. + confirmed = [(nid, nd) for nid, nd in candidates if confirmed_link(nid, prev_id)] + if confirmed: + confirmed.sort(key=lambda x: node_dist(x[1], prev)) + best_id, best = confirmed[0] + else: + candidates.sort(key=lambda x: node_dist(x[1], prev)) + best_id, best = candidates[0] + resolved.insert(0, (best_id, best)) - prev = best + visited.add(best_id) + prev_id = best_id + prev = best # Build adjacency list: src → relays → rx full: list[tuple[str, dict]] = []