558 lines
18 KiB
Markdown
558 lines
18 KiB
Markdown
# RFCP Iteration 3.5.1 — Bugfixes & Polish
|
|
|
|
## Overview
|
|
|
|
Focused bugfix and polish release addressing UI issues, coverage boundary accuracy, history improvements, and GPU indicator fixes discovered during 3.5.0 testing.
|
|
|
|
---
|
|
|
|
## 1. GPU — Detection Not Working + UI Overlap
|
|
|
|
### 1A. GPU Not Detected Despite Being Available
|
|
|
|
**Problem:** User has a laptop with DUAL GPUs (Intel integrated + NVIDIA discrete) but the app only shows "CPU (NumPy)". GPU acceleration is not working at all — no GPU option available in the device selector.
|
|
|
|
**Root cause investigation needed:**
|
|
1. Check if CuPy is actually installed in the Python environment
|
|
2. Check if CUDA toolkit is accessible from the app's runtime
|
|
3. Check if PyOpenCL is installed (fallback for Intel GPU)
|
|
4. The backend GPU detection may be failing silently
|
|
|
|
**Debug steps to add:**
|
|
|
|
```python
|
|
# backend/app/services/gpu_backend.py — improve detection with logging
|
|
|
|
import logging
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@classmethod
|
|
def detect_backends(cls) -> list:
|
|
backends = []
|
|
|
|
# Check NVIDIA CUDA
|
|
try:
|
|
import cupy as cp
|
|
count = cp.cuda.runtime.getDeviceCount()
|
|
logger.info(f"CUDA detected: {count} device(s)")
|
|
for i in range(count):
|
|
device = cp.cuda.Device(i)
|
|
backends.append({...})
|
|
except ImportError:
|
|
logger.warning("CuPy not installed — run: pip install cupy-cuda12x")
|
|
except Exception as e:
|
|
logger.warning(f"CUDA detection failed: {e}")
|
|
|
|
# Check OpenCL (works with Intel, AMD, AND NVIDIA)
|
|
try:
|
|
import pyopencl as cl
|
|
platforms = cl.get_platforms()
|
|
logger.info(f"OpenCL detected: {len(platforms)} platform(s)")
|
|
for platform in platforms:
|
|
for device in platform.get_devices():
|
|
logger.info(f" OpenCL device: {device.name}")
|
|
backends.append({...})
|
|
except ImportError:
|
|
logger.warning("PyOpenCL not installed — run: pip install pyopencl")
|
|
except Exception as e:
|
|
logger.warning(f"OpenCL detection failed: {e}")
|
|
|
|
# Always log what was found
|
|
logger.info(f"Total compute backends: {len(backends)} "
|
|
f"({sum(1 for b in backends if b['type'] == 'cuda')} CUDA, "
|
|
f"{sum(1 for b in backends if b['type'] == 'opencl')} OpenCL)")
|
|
|
|
# CPU always available
|
|
backends.append({...cpu...})
|
|
return backends
|
|
```
|
|
|
|
**Installation check endpoint:**
|
|
|
|
```python
|
|
# backend/app/api/routes/gpu.py — add diagnostic endpoint
|
|
|
|
@router.get("/diagnostics")
|
|
async def gpu_diagnostics():
|
|
"""Full GPU diagnostic info for troubleshooting."""
|
|
diag = {
|
|
"python_version": sys.version,
|
|
"platform": platform.platform(),
|
|
"cuda": {},
|
|
"opencl": {},
|
|
"numpy": {}
|
|
}
|
|
|
|
# Check CuPy/CUDA
|
|
try:
|
|
import cupy
|
|
diag["cuda"]["cupy_version"] = cupy.__version__
|
|
diag["cuda"]["cuda_version"] = cupy.cuda.runtime.runtimeGetVersion()
|
|
diag["cuda"]["device_count"] = cupy.cuda.runtime.getDeviceCount()
|
|
for i in range(diag["cuda"]["device_count"]):
|
|
d = cupy.cuda.Device(i)
|
|
diag["cuda"][f"device_{i}"] = {
|
|
"name": d.name,
|
|
"compute_capability": d.compute_capability,
|
|
"total_memory_mb": d.mem_info[1] // 1024 // 1024
|
|
}
|
|
except ImportError:
|
|
diag["cuda"]["error"] = "CuPy not installed"
|
|
diag["cuda"]["install_hint"] = "pip install cupy-cuda12x --break-system-packages"
|
|
except Exception as e:
|
|
diag["cuda"]["error"] = str(e)
|
|
|
|
# Check PyOpenCL
|
|
try:
|
|
import pyopencl as cl
|
|
diag["opencl"]["pyopencl_version"] = cl.VERSION_TEXT
|
|
for p in cl.get_platforms():
|
|
platform_info = {"name": p.name, "devices": []}
|
|
for d in p.get_devices():
|
|
platform_info["devices"].append({
|
|
"name": d.name,
|
|
"type": cl.device_type.to_string(d.type),
|
|
"memory_mb": d.global_mem_size // 1024 // 1024,
|
|
"compute_units": d.max_compute_units
|
|
})
|
|
diag["opencl"][p.name] = platform_info
|
|
except ImportError:
|
|
diag["opencl"]["error"] = "PyOpenCL not installed"
|
|
diag["opencl"]["install_hint"] = "pip install pyopencl"
|
|
except Exception as e:
|
|
diag["opencl"]["error"] = str(e)
|
|
|
|
# Check NumPy
|
|
import numpy as np
|
|
diag["numpy"]["version"] = np.__version__
|
|
|
|
return diag
|
|
```
|
|
|
|
**Frontend — show diagnostic info:**
|
|
|
|
```typescript
|
|
// In GPUIndicator.tsx — when only CPU detected, show help
|
|
|
|
{devices.length === 1 && devices[0].type === 'cpu' && (
|
|
<div className="text-xs text-yellow-400 mt-2 p-2 bg-yellow-900/20 rounded">
|
|
⚠ No GPU detected.
|
|
<button
|
|
onClick={() => fetchDiagnostics()}
|
|
className="underline ml-1"
|
|
>
|
|
Run diagnostics
|
|
</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
**Auto-install hint in UI:**
|
|
```
|
|
⚠ No GPU detected
|
|
|
|
For NVIDIA GPU: pip install cupy-cuda12x
|
|
For Intel/AMD GPU: pip install pyopencl
|
|
|
|
[Run Diagnostics] [Install Guide]
|
|
```
|
|
|
|
**Dual GPU handling (Intel + NVIDIA laptop):**
|
|
```python
|
|
# When both Intel (OpenCL) and NVIDIA (CUDA) found:
|
|
# - List both in device selector
|
|
# - Default to NVIDIA CUDA (faster)
|
|
# - Allow user to switch
|
|
# - Intel iGPU via OpenCL is still ~3-5x faster than CPU
|
|
|
|
# Example device list for dual GPU laptop:
|
|
# 1. ⚡ NVIDIA GeForce RTX 4060 (CUDA) — 8 GB [DEFAULT]
|
|
# 2. ⚡ Intel UHD Graphics 770 (OpenCL) — shared memory
|
|
# 3. 💻 CPU (16 cores)
|
|
```
|
|
|
|
### 1B. GPU Indicator UI — Fix Overlap with Fit Button
|
|
|
|
**Problem:** GPU device dropdown overlaps with the "Fit" button in top-right corner.
|
|
|
|
**Solution:**
|
|
- Keep compact "⚡ CPU" badge in header
|
|
- Dropdown opens to the LEFT or DOWNWARD, not overlapping map controls
|
|
- Proper z-index and positioning
|
|
- Shorter labels: "CPU" not "CPU (NumPy)"
|
|
|
|
**Files:**
|
|
- `frontend/src/components/ui/GPUIndicator.tsx`
|
|
- `backend/app/services/gpu_backend.py`
|
|
- `backend/app/api/routes/gpu.py`
|
|
|
|
---
|
|
|
|
## 2. Coverage Boundary — Improve Accuracy
|
|
|
|
**Problem:** Current boundary shows a rough circle/ellipse shape that doesn't follow actual coverage contour.
|
|
|
|
**Current behavior:** Boundary seems to be based on simple distance radius rather than actual RSRP threshold contour.
|
|
|
|
**Expected behavior:** Boundary should follow the actual -100 dBm (or configured threshold) contour line — an irregular shape that follows terrain, buildings, vegetation shadows.
|
|
|
|
**Solution:**
|
|
|
|
```python
|
|
# Backend approach: Generate contour from actual RSRP grid
|
|
|
|
import numpy as np
|
|
from scipy.ndimage import binary_dilation, binary_erosion
|
|
from shapely.geometry import MultiPoint
|
|
from shapely.ops import unary_union
|
|
|
|
def calculate_coverage_boundary(points: list, threshold_dbm: float = -100) -> list:
|
|
"""
|
|
Calculate coverage boundary as convex hull of points above threshold.
|
|
Returns list of [lat, lon] coordinates forming the boundary polygon.
|
|
"""
|
|
# Filter points above threshold
|
|
valid_points = [(p['lat'], p['lon']) for p in points if p['rsrp'] >= threshold_dbm]
|
|
|
|
if len(valid_points) < 3:
|
|
return []
|
|
|
|
# Create concave hull (alpha shape) for realistic boundary
|
|
# Concave hull follows the actual shape better than convex hull
|
|
from shapely.geometry import MultiPoint
|
|
multi_point = MultiPoint(valid_points)
|
|
|
|
# Alpha shape — adjust alpha for detail level
|
|
# Higher alpha = more detailed (but slower)
|
|
boundary = concave_hull(multi_point, ratio=0.3)
|
|
|
|
if boundary.is_empty:
|
|
return []
|
|
|
|
# Simplify to reduce points (tolerance in degrees ≈ 100m)
|
|
simplified = boundary.simplify(0.001)
|
|
|
|
# Return as coordinate list
|
|
coords = list(simplified.exterior.coords)
|
|
return [[lat, lon] for lat, lon in coords]
|
|
```
|
|
|
|
```python
|
|
# Alternative: Grid-based contour approach
|
|
|
|
def calculate_boundary_from_grid(
|
|
grid_points: list,
|
|
threshold_dbm: float,
|
|
grid_resolution_m: float
|
|
) -> list:
|
|
"""
|
|
Create boundary by finding edge cells of coverage area.
|
|
More accurate than hull — follows actual coverage gaps.
|
|
"""
|
|
import numpy as np
|
|
|
|
# Build 2D RSRP grid
|
|
lats = sorted(set(p['lat'] for p in grid_points))
|
|
lons = sorted(set(p['lon'] for p in grid_points))
|
|
|
|
grid = np.full((len(lats), len(lons)), np.nan)
|
|
lat_idx = {lat: i for i, lat in enumerate(lats)}
|
|
lon_idx = {lon: i for i, lon in enumerate(lons)}
|
|
|
|
for p in grid_points:
|
|
i = lat_idx[p['lat']]
|
|
j = lon_idx[p['lon']]
|
|
grid[i, j] = p['rsrp']
|
|
|
|
# Binary mask: above threshold
|
|
mask = grid >= threshold_dbm
|
|
|
|
# Find boundary: dilate - original = edge cells
|
|
dilated = binary_dilation(mask)
|
|
boundary_mask = dilated & ~mask
|
|
|
|
# Extract boundary coordinates
|
|
boundary_coords = []
|
|
for i in range(len(lats)):
|
|
for j in range(len(lons)):
|
|
if boundary_mask[i, j]:
|
|
boundary_coords.append([lats[i], lons[j]])
|
|
|
|
# Order points for polygon (traveling salesman approximate)
|
|
if len(boundary_coords) > 2:
|
|
ordered = order_boundary_points(boundary_coords)
|
|
return ordered
|
|
|
|
return boundary_coords
|
|
```
|
|
|
|
**Frontend changes:**
|
|
- Receive boundary polygon from backend (already calculated with results)
|
|
- Or calculate client-side from grid points
|
|
- Render as Leaflet polygon with dashed white stroke
|
|
- Should follow actual coverage shape, not circular approximation
|
|
|
|
**Files:**
|
|
- `backend/app/services/coverage_service.py` — add boundary calculation
|
|
- `frontend/src/components/map/CoverageBoundary.tsx` — render real contour
|
|
|
|
---
|
|
|
|
## 3. Session History — Show Propagation Parameters
|
|
|
|
**Problem:** History entries only show preset, points, radius, resolution. Missing propagation settings used.
|
|
|
|
**Solution:** Save full propagation config snapshot with each history entry.
|
|
|
|
```typescript
|
|
// frontend/src/store/calcHistory.ts
|
|
|
|
interface HistoryEntry {
|
|
id: string;
|
|
timestamp: Date;
|
|
computationTime: number;
|
|
preset: string;
|
|
radius: number;
|
|
resolution: number;
|
|
totalPoints: number;
|
|
|
|
// Coverage results
|
|
coverage: {
|
|
excellent: number; // percentage
|
|
good: number;
|
|
fair: number;
|
|
weak: number;
|
|
};
|
|
avgRsrp: number;
|
|
rangeMin: number;
|
|
rangeMax: number;
|
|
|
|
// NEW: Propagation parameters snapshot
|
|
propagation: {
|
|
modelsUsed: string[]; // ["Free-Space", "terrain_los", ...]
|
|
modelCount: number; // 12
|
|
frequency: number; // 2100 MHz
|
|
txPower: number; // 46 dBm
|
|
antennaGain: number; // 15 dBi
|
|
antennaHeight: number; // 10 m
|
|
|
|
// Environment
|
|
season: string; // "Winter (30%)"
|
|
temperature: string; // "15°C (mild)"
|
|
humidity: string; // "50% (normal)"
|
|
rainConditions: string; // "Light Rain"
|
|
indoorCoverage: string; // "Medium Building (brick)"
|
|
|
|
// Margins
|
|
fadingMargin: number; // 0 dB
|
|
|
|
// Atmospheric
|
|
atmosphericAbsorption: boolean;
|
|
};
|
|
|
|
// Site config
|
|
sites: number; // 2
|
|
sectors: number; // total sectors
|
|
}
|
|
```
|
|
|
|
**Display in History panel:**
|
|
|
|
```typescript
|
|
// Expanded history entry shows propagation details
|
|
|
|
<div className="history-entry-expanded">
|
|
{/* Existing: time, points, coverage bars */}
|
|
|
|
{/* NEW: Propagation summary (collapsed by default) */}
|
|
<details className="mt-2">
|
|
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300">
|
|
▸ Propagation: {entry.propagation.modelCount} models, {entry.propagation.frequency} MHz
|
|
</summary>
|
|
<div className="mt-1 pl-3 text-xs text-gray-500 space-y-0.5">
|
|
<div>TX: {entry.propagation.txPower} dBm, Gain: {entry.propagation.antennaGain} dBi</div>
|
|
<div>Height: {entry.propagation.antennaHeight}m</div>
|
|
<div>Environment: {entry.propagation.season}, {entry.propagation.rainConditions}</div>
|
|
<div>Indoor: {entry.propagation.indoorCoverage}</div>
|
|
{entry.propagation.fadingMargin > 0 && (
|
|
<div>Fading margin: {entry.propagation.fadingMargin} dB</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{entry.propagation.modelsUsed.map(model => (
|
|
<span key={model} className="px-1 py-0.5 bg-slate-700 rounded text-[10px]">
|
|
{model}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
```
|
|
|
|
**Files:**
|
|
- `frontend/src/store/calcHistory.ts` — extend HistoryEntry type, save propagation
|
|
- `frontend/src/components/panels/HistoryPanel.tsx` — show expandable propagation details
|
|
- `backend/app/api/websocket.py` — include propagation config in result message
|
|
- `backend/app/services/coverage_service.py` — return config snapshot with results
|
|
|
|
---
|
|
|
|
## 4. Results Popup — Show Propagation Summary
|
|
|
|
**Problem:** Calculation Complete popup shows time, points, coverage bars — but not which models were used.
|
|
|
|
**Solution:** Add compact propagation info to results popup.
|
|
|
|
```typescript
|
|
// frontend/src/components/ui/ResultsPopup.tsx
|
|
|
|
// Add below coverage bars:
|
|
<div className="mt-2 text-xs text-gray-400">
|
|
<span>{result.modelsUsed?.length || 0} models</span>
|
|
<span className="mx-1">•</span>
|
|
<span>{result.frequency} MHz</span>
|
|
{result.fadingMargin > 0 && (
|
|
<>
|
|
<span className="mx-1">•</span>
|
|
<span>FM: {result.fadingMargin} dB</span>
|
|
</>
|
|
)}
|
|
{result.indoorCoverage && result.indoorCoverage !== 'none' && (
|
|
<>
|
|
<span className="mx-1">•</span>
|
|
<span>Indoor: {result.indoorCoverage}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
**Files:**
|
|
- `frontend/src/components/ui/ResultsPopup.tsx`
|
|
|
|
---
|
|
|
|
## 5. Batch Frequency Change (from 3.5.0 backlog)
|
|
|
|
**Problem:** To compare coverage at different frequencies, user must edit each sector manually.
|
|
|
|
**Solution:** Quick-change buttons in toolbar or Coverage Settings.
|
|
|
|
```typescript
|
|
// frontend/src/components/panels/BatchOperations.tsx (NEW)
|
|
|
|
const QUICK_BANDS = [
|
|
{ freq: 700, label: '700', band: 'B28', color: 'text-red-400' },
|
|
{ freq: 800, label: '800', band: 'B20', color: 'text-orange-400' },
|
|
{ freq: 900, label: '900', band: 'B8', color: 'text-yellow-400' },
|
|
{ freq: 1800, label: '1800', band: 'B3', color: 'text-green-400' },
|
|
{ freq: 2100, label: '2100', band: 'B1', color: 'text-blue-400' },
|
|
{ freq: 2600, label: '2600', band: 'B7', color: 'text-purple-400' },
|
|
{ freq: 3500, label: '3500', band: 'n78', color: 'text-pink-400' },
|
|
];
|
|
|
|
export function BatchFrequencyChange() {
|
|
return (
|
|
<div className="p-3 border-t border-slate-700">
|
|
<h4 className="text-xs font-semibold text-gray-400 mb-2">
|
|
SET ALL SECTORS
|
|
</h4>
|
|
<div className="flex flex-wrap gap-1">
|
|
{QUICK_BANDS.map(b => (
|
|
<button
|
|
key={b.freq}
|
|
onClick={() => setAllSectorsFrequency(b.freq)}
|
|
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded"
|
|
title={`${b.band} — ${b.freq} MHz`}
|
|
>
|
|
<span className={b.color}>{b.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Location:** Below site list, above Coverage Settings.
|
|
|
|
**Files:**
|
|
- `frontend/src/components/panels/BatchOperations.tsx` (NEW)
|
|
- `frontend/src/store/sites.ts` — add `setAllSectorsFrequency()` action
|
|
|
|
---
|
|
|
|
## 6. Minor UI Fixes
|
|
|
|
### 6.1 Terrain Profile — Click Propagation (verify fix)
|
|
- Verify that clicking "Terrain Profile" button no longer adds ruler point
|
|
- If still broken: ensure e.stopPropagation() AND e.preventDefault() on button
|
|
|
|
### 6.2 GPU Indicator — Shorter Label
|
|
- Current: "CPU (NumPy)" — too long
|
|
- Should be: "CPU" or "⚡ CPU"
|
|
- When GPU active: "⚡ RTX 4060" (short device name)
|
|
|
|
### 6.3 ~~Coordinate Display — Show Elevation~~ ✅ WORKS
|
|
- Elevation loads on hover with delay — NOT a bug
|
|
- Shows "Elev: 380m ASL" after holding cursor on map
|
|
- No fix needed
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
### Priority 1 — Quick Fixes (30 min)
|
|
- [ ] GPU indicator positioning (no overlap with Fit)
|
|
- [ ] GPU detection — install CuPy/PyOpenCL, diagnostics endpoint
|
|
- [ ] Terrain Profile click fix (verify)
|
|
|
|
### Priority 2 — History Enhancement (1 hour)
|
|
- [ ] Extend HistoryEntry with propagation params
|
|
- [ ] Save propagation snapshot on calculation complete
|
|
- [ ] Expandable propagation details in History panel
|
|
- [ ] Results popup — show model count + frequency
|
|
|
|
### Priority 3 — Coverage Boundary (1-2 hours)
|
|
- [ ] Implement contour-based boundary from actual RSRP grid
|
|
- [ ] Replace circular approximation with real coverage shape
|
|
- [ ] Test with multi-site calculations
|
|
- [ ] Smooth boundary line (simplify polygon)
|
|
|
|
### Priority 4 — Batch Frequency (30 min)
|
|
- [ ] BatchOperations component
|
|
- [ ] setAllSectorsFrequency store action
|
|
- [ ] Wire into sidebar panel
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
- [ ] GPU indicator does not overlap with any map controls
|
|
- [ ] Coverage boundary follows actual coverage shape (not circular)
|
|
- [ ] History entries show expandable propagation parameters
|
|
- [ ] Results popup shows model count and frequency
|
|
- [ ] Batch frequency change updates all sectors at once
|
|
- [ ] Terrain Profile button click doesn't add ruler point
|
|
- [ ] Elevation displays correctly in bottom-left
|
|
|
|
---
|
|
|
|
## Files Summary
|
|
|
|
### New Files
|
|
- `frontend/src/components/panels/BatchOperations.tsx`
|
|
|
|
### Modified Files
|
|
- `frontend/src/components/ui/GPUIndicator.tsx` — fix position/overlap
|
|
- `frontend/src/components/map/CoverageBoundary.tsx` — real contour
|
|
- `frontend/src/components/ui/ResultsPopup.tsx` — propagation info
|
|
- `frontend/src/store/calcHistory.ts` — extended HistoryEntry
|
|
- `frontend/src/components/panels/HistoryPanel.tsx` — expandable details
|
|
- `frontend/src/store/sites.ts` — batch frequency action
|
|
- `backend/app/services/coverage_service.py` — boundary calculation, config snapshot
|
|
- `backend/app/api/websocket.py` — include config in results
|
|
|
|
---
|
|
|
|
*"Polish makes the difference between a tool and a product"* ✨
|