mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-23 22:32:52 +00:00
Live confirmed-link learning and real-time frontend updates
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb413a2f68
commit
4969ac28b7
3 files changed
+66
-10
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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<MeshNode> & { 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]);
|
||||
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
Reference in new issue
Block a user