Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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**.

Expand All @@ -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.
Expand Down
68 changes: 45 additions & 23 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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.*
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "meshrf",
"private": true,
"version": "1.13.0",
"version": "1.14.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
8 changes: 6 additions & 2 deletions rf-engine/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})

Expand Down
103 changes: 99 additions & 4 deletions rf-engine/tasks/viewshed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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']
Expand All @@ -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]
Expand Down
26 changes: 23 additions & 3 deletions src/components/Map/MapContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ImageOverlay,
Marker,
Popup,
Polyline,
Rectangle,
ZoomControl,
} from "react-leaflet";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -835,6 +836,23 @@ const MapComponent = () => {
</Marker>
))}

{/* 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 (
<Polyline
key={`link-${i}`}
positions={[[nodeA.lat, nodeA.lon], [nodeB.lat, nodeB.lon]]}
pathOptions={{ color, weight: 2, opacity: 0.85, dashArray }}
/>
);
})}

{/* Batch Nodes Panel - Must be inside MapContainer to use useMap hook */}
{showBatchPanel && batchNodes.length > 0 && (
<BatchNodesPanelWrapper
Expand Down Expand Up @@ -1056,8 +1074,10 @@ const MapComponent = () => {

{/* Site Analysis Results Panel moved outside to prevent click-through */}
{showAnalysisResults && simResults && simResults.length > 0 && (
<SiteAnalysisResultsPanel
<SiteAnalysisResultsPanel
results={simResults}
interNodeLinks={interNodeLinks}
totalUniqueCoverageKm2={totalUniqueCoverageKm2}
units={units}
onCenter={(res) => {
if (map) {
Expand All @@ -1071,7 +1091,7 @@ const MapComponent = () => {
}}
onRunNew={() => {
setShowAnalysisResults(false);
setToolMode('optimize');
setToolMode('optimize');
setSiteAnalysisMode('manual');
}}
/>
Expand Down
Loading
Loading