diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cfc45f..d76b14e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.14.0] - 2026-02-10
+
+### Added
+
+- **Inter-Node Link Matrix**: The Multi-Site scan now runs pairwise RF link analysis between every selected site after the viewshed completes. Each pair reports:
+ - **Path Loss (dB)** — Bullington terrain-diffraction model using the same RF parameters as the rest of the tool.
+ - **Fresnel Clearance %** — ratio of clearance to first Fresnel zone at the worst obstruction point.
+ - **Status** — `Viable` (≥60% Fresnel), `Degraded` (0–60%), or `Blocked` (<0%).
+ - **Distance** — haversine distance between the two sites (metric/imperial).
+- **Marginal Coverage Metric**: Each site card now shows how much *unique* area it contributes to the network — area not already covered by any other selected site. Low unique-coverage % flags redundant placements.
+- **Connectivity Score**: Each site now displays how many of the other selected sites it can reach (viable + degraded links), giving an immediate indicator of network centrality.
+- **Combined Coverage Total**: The results panel header now shows the total union area covered by all selected sites combined.
+- **Mesh Topology Tab**: New "Topology" tab in the results panel features:
+ - **Mesh Connectivity Score** — percentage of all node pairs reachable (direct or via relay).
+ - Direct link breakdown by status (Viable / Degraded / Blocked).
+ - **Multi-hop relay detection** — BFS pathfinding identifies pairs that cannot link directly but remain reachable through intermediate nodes, with hop counts.
+ - All-pairs path table showing the shortest viable route between every combination of sites.
+- **Link Lines on Map**: Colored polylines are now drawn between every site pair when results are displayed.
+ - Solid cyan = viable direct link.
+ - Dashed gold = degraded link (partial Fresnel obstruction).
+ - Dashed red = blocked link (terrain obstruction).
+- **Tab-aware Help**: The Help overlay now shows context-specific field definitions for whichever tab is active (Sites, Links, or Topology).
+- **Node names in results**: Site names from CSV imports or manual entry are now preserved through the scan and displayed in all tabs.
+
+### Changed
+
+- **Results Panel redesigned** with three tabs replacing the single flat card list:
+ - **Sites** — individual site metrics (elevation, total coverage, unique coverage %, link count).
+ - **Links** — sorted link matrix (viable links first, blocked last).
+ - **Topology** — mesh health overview and full path table.
+- **Scan endpoint** now forwards frequency, rx height, K-factor, and clutter height into the Celery task so pairwise link analysis uses the correct RF parameters.
+- **Panel width** increased to 380px to accommodate the new four-column metrics grid.
+
## [1.13.0] - 2026-02-09
### Added
diff --git a/README.md b/README.md
index 9a01332..9273875 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# meshRF 📡 v1.13.0
+# meshRF 📡 v1.14.0
A professional-grade RF propagation and link analysis tool designed for LoRa Mesh networks (Meshtastic, Reticulum, Sidewinder). Built with **React**, **Leaflet**, and a high-fidelity physics core combining a **Python Geodetic Engine** with **High-Performance WASM Modules**.
@@ -24,6 +24,10 @@ meshRF is designed for **mission-critical availability**. It operates with **zer
### 2. 📍 Advanced Site Surveying
- **Multi-Site Management**: Dedicated manager for maintaining and comparing lists of candidate sites.
+ - **Inter-Node Link Matrix**: Automatically analyse pairwise RF link quality (path loss, Fresnel clearance, Viable/Degraded/Blocked) between every selected site after a scan.
+ - **Marginal Coverage**: Per-site unique coverage percentage highlights redundant placements before deployment.
+ - **Mesh Topology**: BFS-based connectivity score, multi-hop relay detection, and all-pairs path table — see if your proposed network forms a true connected mesh.
+ - **Link Visualisation**: Coloured polylines drawn on the map between every site pair (cyan = viable, gold = degraded, red = blocked).
- **Coverage Analysis**: Scan a radial area around your transmitter to identify optimal reception points based on LOS, Fresnel clearance, and signal strength.
- **RF Coverage Simulator**: Optimized Wasm-powered ITM propagation modeling for wide-area coverage visualization.
- **Viewshed Analysis**: Desktop-grade viewshed calculations with "Shadow Mode" visualization.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index c79cf0d..b6e25b3 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,40 +1,62 @@
-# Release v1.13.0: The "Coverage Analysis" Update 📡
+# Release v1.14.0: Mesh Link Intelligence
-This major feature update transforms the old "Site Finder" into a professional-grade **Coverage Analysis** tool. We've replaced the rigid grid system with a flexible **Radial Scan** engine, allowing you to instantly visualize signal quality around any transmitter.
+This release transforms the Multi-Site Analysis tool from a simple coverage area reporter into a full **mesh network planning suite**. After running a scan you can now immediately answer the questions that matter most: which sites can talk to each other, how much does each site uniquely contribute, and is the proposed network actually a connected mesh?
-## 🌟 Key Features
+## What's New
-### 1. 🎯 Radial Coverage Analysis
+### 1. Inter-Node Link Quality Matrix
-- **Click-to-Scan**: Simply click the map to place your Transmitter (TX), then drag to set your scan radius (up to 20km).
-- **Heatmap Overlay**: See a color-coded signal quality layer that shows you exactly where reception is strongest (Green) or weakest (Red).
-- **Best Links**: The system automatically identifies and ranks the top reception sites based on Line-of-Sight, Fresnel Clearance, and Signal Strength.
+Every site pair in your scan is now automatically analysed for RF link viability using the same Bullington terrain-diffraction model used elsewhere in the tool. The new **Links tab** shows:
-### 2. 🏔️ Interactive Terrain Profiles
+| Field | What it tells you |
+|---|---|
+| **Status** | Viable / Degraded / Blocked based on Fresnel zone clearance |
+| **Path Loss** | End-to-end path loss in dB — compare against your link budget |
+| **Fresnel Clearance** | % of first Fresnel zone clear at the worst obstruction point |
+| **Distance** | Haversine distance between the two sites |
-- **Deep Dive**: Click any "Best Link" marker to open a detailed **Terrain Profile**.
-- **Visual Physics**: See the actual ground elevation, the direct Line-of-Sight path, and the Fresnel Zone clearance in a beautiful, interactive chart.
+Links are sorted viable-first so the best candidates are always at the top.
-### 3. 💾 Data Export
+### 2. Marginal Coverage per Site
-- **Take it with you**: Export your analysis results to **CSV** for spreadsheets or **KML** for 3D visualization in Google Earth.
+The **Sites tab** now shows how much *unique* area each node contributes — area that no other selected site covers. This catches redundant placements before you deploy:
-### 4. 🎛️ Advanced RF Controls
+- **High unique %** (cyan) → critical site, removing it creates a coverage gap.
+- **Low unique %** (red) → redundant placement, consider relocating.
-- **Fine-Tuning**: Adjust **Refraction (K-Factor)** and **Clutter Height** directly from the map interface to model different atmospheric conditions and environments.
+### 3. Mesh Connectivity Score & Topology Tab
-## 🛠️ Enhancements
+The new **Topology tab** gives a network-level health summary:
-- **Renamed**: "Elevation Scan" is now **Coverage Analysis** to better reflect its capabilities.
-- **Polished**: Scroll propagation is now blocked in panels, preventing accidental map zooms while viewing results.
-- **Clarified**: Tooltips and guidance overlays have been rewritten for clarity.
+- **Mesh Connectivity Score** — percentage of all node pairs that can communicate, either directly or via relay. A fully connected mesh scores 100%.
+- **Multi-hop relay detection** — BFS pathfinding finds pairs that are blocked on a direct link but reachable through intermediate nodes, with the hop count shown.
+- **All-pairs path table** — every combination of sites with its shortest viable path and status.
-## 🚀 How to Upgrade
+### 4. Link Lines on the Map
-1. Pull the latest: `git pull origin main`
-2. Update dependencies: `docker exec meshrf_dev npm install`
-3. Restart containers: `docker compose -f docker-compose.dev.yml restart`
+Coloured polylines are drawn between every site pair when results are shown, so the topology is visible directly on the terrain:
+
+- **Solid cyan** — viable direct link
+- **Dashed gold** — degraded link (partial terrain obstruction)
+- **Dashed red** — blocked (no direct LOS path)
+
+### 5. Combined Coverage Total
+
+The results panel header now shows the total **union coverage area** of all selected sites — the actual unique terrain covered by the entire proposed network, not the sum of individual footprints.
+
+## Improvements
+
+- Site names from CSV imports are now preserved through the scan and appear in all three tabs and on the map polyline tooltips.
+- The Help button is now context-aware — it explains the fields for whichever tab you have open.
+- The `/scan/start` API endpoint now forwards RF parameters (frequency, K-factor, clutter height) into the Celery task so link analysis uses your configured settings.
+
+## How to Upgrade
+
+```bash
+git pull origin main
+docker compose -f docker-compose.dev.yml up -d --build
+```
---
-_See the unseen. Happy scanning!_
+*Plan smarter. Know your mesh before you deploy.*
diff --git a/package.json b/package.json
index e116cdf..981ee54 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "meshrf",
"private": true,
- "version": "1.13.0",
+ "version": "1.14.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/rf-engine/server.py b/rf-engine/server.py
index bdb4d5d..116a73f 100644
--- a/rf-engine/server.py
+++ b/rf-engine/server.py
@@ -200,8 +200,12 @@ def start_scan_endpoint(req: dict):
task = calculate_batch_viewshed.delay({
"nodes": nodes,
"options": {
- "radius": 5000,
- "optimize_n": optimize_n
+ "radius": req.get("radius", 5000),
+ "optimize_n": optimize_n,
+ "frequency_mhz": req.get("frequency_mhz", 915.0),
+ "rx_height": req.get("rx_height", 2.0),
+ "k_factor": req.get("k_factor", 1.333),
+ "clutter_height": req.get("clutter_height", 0.0)
}
})
diff --git a/rf-engine/tasks/viewshed.py b/rf-engine/tasks/viewshed.py
index 889426b..eb64a9d 100644
--- a/rf-engine/tasks/viewshed.py
+++ b/rf-engine/tasks/viewshed.py
@@ -9,6 +9,7 @@
from core.algorithms import calculate_viewshed
from tile_manager import TileManager
from models import NodeConfig
+import rf_physics
logger = get_task_logger(__name__)
@@ -92,6 +93,8 @@ def lon_to_x(lon):
node_res = {
"lat": lat, "lon": lon,
+ "name": node_data.get('name', f'Site {i + 1}'),
+ "height": height,
"elevation": round(float(source_elev), 1),
"coverage_area_km2": round((coverage_count * (res_m * res_m)) / 1_000_000.0, 2),
"grid": grid,
@@ -168,7 +171,85 @@ def lon_to_x(lon):
# No more gain to be had (or empty)
break
- # 3. Blit to Master Grid and generate Composite
+ # 3. Compute marginal coverage for each selected node (in selection order)
+ covered_so_far = set()
+ for res in selected_results:
+ g = res['grid']
+ lats_g = res['grid_lats']
+ lons_g = res['grid_lons']
+ visible_indices = np.argwhere(g > 0)
+ node_pixels = set()
+ for r, c in visible_indices:
+ y = lat_to_y(lats_g[r])
+ x = lon_to_x(lons_g[c])
+ if 0 <= y < rows and 0 <= x < cols:
+ node_pixels.add((y, x))
+ marginal_pixels = len(node_pixels - covered_so_far)
+ covered_so_far.update(node_pixels)
+ res['marginal_coverage_km2'] = round((marginal_pixels * (res_m * res_m)) / 1_000_000.0, 2)
+
+ total_unique_km2 = round((len(covered_so_far) * (res_m * res_m)) / 1_000_000.0, 2)
+ for res in selected_results:
+ total_cov = res['coverage_area_km2']
+ res['unique_coverage_pct'] = round(
+ (res['marginal_coverage_km2'] / total_cov * 100) if total_cov > 0 else 0.0, 1
+ )
+
+ # 3a. Compute pairwise inter-node link quality
+ self.update_state(state='PROGRESS', meta={'progress': 55, 'message': 'Analyzing inter-node links...'})
+ inter_node_links = []
+ n_selected = len(selected_results)
+ for i in range(n_selected):
+ for j in range(i + 1, n_selected):
+ node_a = selected_results[i]
+ node_b = selected_results[j]
+ try:
+ dist_m = rf_physics.haversine_distance(
+ node_a['lat'], node_a['lon'],
+ node_b['lat'], node_b['lon']
+ )
+ elevs = tile_manager.get_elevation_profile(
+ node_a['lat'], node_a['lon'],
+ node_b['lat'], node_b['lon'],
+ samples=50
+ )
+ h_a = node_a.get('height', 10.0)
+ h_b = node_b.get('height', 10.0)
+ link_result = rf_physics.analyze_link(
+ elevs, dist_m, freq, h_a, h_b,
+ k_factor=options.get('k_factor', 1.333),
+ clutter_height=options.get('clutter_height', 0.0)
+ )
+ path_loss_db = rf_physics.calculate_path_loss(
+ dist_m, elevs, freq, h_a, h_b,
+ model='bullington',
+ k_factor=options.get('k_factor', 1.333),
+ clutter_height=options.get('clutter_height', 0.0)
+ )
+ inter_node_links.append({
+ "node_a_idx": i,
+ "node_b_idx": j,
+ "node_a_name": node_a.get('name', f'Site {i + 1}'),
+ "node_b_name": node_b.get('name', f'Site {j + 1}'),
+ "dist_km": round(dist_m / 1000, 2),
+ "status": link_result['status'],
+ "path_loss_db": round(float(path_loss_db), 1),
+ "min_clearance_ratio": round(float(link_result['min_clearance_ratio']), 2)
+ })
+ except Exception as e:
+ logger.error(f"Link analysis failed for nodes {i}-{j}: {e}")
+ inter_node_links.append({
+ "node_a_idx": i,
+ "node_b_idx": j,
+ "node_a_name": selected_results[i].get('name', f'Site {i + 1}'),
+ "node_b_name": selected_results[j].get('name', f'Site {j + 1}'),
+ "dist_km": 0,
+ "status": "unknown",
+ "path_loss_db": 0,
+ "min_clearance_ratio": 0
+ })
+
+ # 4. Blit to Master Grid and generate Composite
for res in selected_results:
g = res['grid']
lats = res['grid_lats']
@@ -190,17 +271,31 @@ def lon_to_x(lon):
# 5. Build Final Output
final_results = []
- for res in selected_results:
+ for idx, res in enumerate(selected_results):
final_results.append({
"lat": res["lat"],
"lon": res["lon"],
+ "name": res.get("name", f"Site {idx + 1}"),
"elevation": res["elevation"],
- "coverage_area_km2": res["coverage_area_km2"]
+ "coverage_area_km2": res["coverage_area_km2"],
+ "marginal_coverage_km2": res.get("marginal_coverage_km2", res["coverage_area_km2"]),
+ "unique_coverage_pct": res.get("unique_coverage_pct", 100.0)
})
+ # Compute connectivity score per node (# of viable/degraded links)
+ connectivity = [0] * len(final_results)
+ for link in inter_node_links:
+ if link["status"] in ("viable", "degraded"):
+ connectivity[link["node_a_idx"]] += 1
+ connectivity[link["node_b_idx"]] += 1
+ for idx, res in enumerate(final_results):
+ res["connectivity_score"] = connectivity[idx]
+
return {
- "status": "completed",
+ "status": "completed",
"results": final_results,
+ "inter_node_links": inter_node_links,
+ "total_unique_coverage_km2": total_unique_km2,
"composite": {
"image": f"data:image/png;base64,{img_str}",
"bounds": [min_lat, min_lon, max_lat, max_lon]
diff --git a/src/components/Map/MapContainer.jsx b/src/components/Map/MapContainer.jsx
index 42b58ae..26eb248 100644
--- a/src/components/Map/MapContainer.jsx
+++ b/src/components/Map/MapContainer.jsx
@@ -5,6 +5,7 @@ import {
ImageOverlay,
Marker,
Popup,
+ Polyline,
Rectangle,
ZoomControl,
} from "react-leaflet";
@@ -346,7 +347,7 @@ const MapComponent = () => {
// Simulation Store integration
- const { nodes: simNodes, results: simResults, compositeOverlay } = useSimulationStore();
+ const { nodes: simNodes, results: simResults, compositeOverlay, interNodeLinks, totalUniqueCoverageKm2 } = useSimulationStore();
// Automatically show results panel when scan finishes
useEffect(() => {
@@ -835,6 +836,23 @@ const MapComponent = () => {
))}
+ {/* Inter-node link quality polylines */}
+ {showAnalysisResults && simResults && interNodeLinks && interNodeLinks.map((link, i) => {
+ const nodeA = simResults[link.node_a_idx];
+ const nodeB = simResults[link.node_b_idx];
+ if (!nodeA || !nodeB) return null;
+ const colorMap = { viable: '#00f2ff', degraded: '#ffd700', blocked: '#ff4444', unknown: '#888' };
+ const color = colorMap[link.status] || '#888';
+ const dashArray = link.status === 'blocked' ? '6 6' : link.status === 'degraded' ? '10 4' : null;
+ return (
+
+ );
+ })}
+
{/* Batch Nodes Panel - Must be inside MapContainer to use useMap hook */}
{showBatchPanel && batchNodes.length > 0 && (
{
{/* Site Analysis Results Panel moved outside to prevent click-through */}
{showAnalysisResults && simResults && simResults.length > 0 && (
- {
if (map) {
@@ -1071,7 +1091,7 @@ const MapComponent = () => {
}}
onRunNew={() => {
setShowAnalysisResults(false);
- setToolMode('optimize');
+ setToolMode('optimize');
setSiteAnalysisMode('manual');
}}
/>
diff --git a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx
index 838ea5c..3358c51 100644
--- a/src/components/Map/UI/SiteAnalysisResultsPanel.jsx
+++ b/src/components/Map/UI/SiteAnalysisResultsPanel.jsx
@@ -1,8 +1,380 @@
import React, { useState, useEffect } from 'react';
-const SiteAnalysisResultsPanel = ({ results, onClose, onCenter, onClear, onRunNew, units }) => {
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+const STATUS_COLORS = {
+ viable: '#00f2ff',
+ degraded: '#ffd700',
+ blocked: '#ff4444',
+ unknown: '#888',
+};
+
+const STATUS_LABELS = {
+ viable: 'Viable',
+ degraded: 'Degraded',
+ blocked: 'Blocked',
+ unknown: 'Unknown',
+};
+
+function statusBadge(status) {
+ const color = STATUS_COLORS[status] || STATUS_COLORS.unknown;
+ return (
+
+ {STATUS_LABELS[status] || status}
+
+ );
+}
+
+/** Build adjacency list and find all viable multi-hop paths between every pair */
+function findMeshPaths(results, interNodeLinks) {
+ if (!results || !interNodeLinks) return [];
+ const n = results.length;
+ // adjacency: node_idx -> list of {neighbor, status}
+ const adj = Array.from({ length: n }, () => []);
+ for (const link of interNodeLinks) {
+ if (link.status === 'viable' || link.status === 'degraded') {
+ adj[link.node_a_idx].push({ neighbor: link.node_b_idx, status: link.status });
+ adj[link.node_b_idx].push({ neighbor: link.node_a_idx, status: link.status });
+ }
+ }
+
+ const paths = [];
+ // BFS shortest path for all pairs
+ for (let src = 0; src < n; src++) {
+ for (let dst = src + 1; dst < n; dst++) {
+ // BFS
+ const visited = new Array(n).fill(false);
+ const queue = [{ node: src, path: [src], worstStatus: 'viable' }];
+ visited[src] = true;
+ let found = null;
+ while (queue.length > 0 && !found) {
+ const { node, path, worstStatus } = queue.shift();
+ for (const { neighbor, status } of adj[node]) {
+ if (!visited[neighbor]) {
+ const newWorst = (worstStatus === 'degraded' || status === 'degraded') ? 'degraded' : 'viable';
+ const newPath = [...path, neighbor];
+ if (neighbor === dst) {
+ found = { path: newPath, status: newWorst };
+ } else {
+ visited[neighbor] = true;
+ queue.push({ node: neighbor, path: newPath, worstStatus: newWorst });
+ }
+ }
+ }
+ }
+ if (found) {
+ paths.push({
+ src,
+ dst,
+ path: found.path,
+ status: found.status,
+ hops: found.path.length - 1
+ });
+ } else {
+ paths.push({ src, dst, path: [src, dst], status: 'blocked', hops: 1 });
+ }
+ }
+ }
+ return paths;
+}
+
+// ─── Tabs ─────────────────────────────────────────────────────────────────────
+
+function SitesTab({ results, units, onCenter }) {
+ return (
+