@mytec: iter3.10 start, baseline rc ready

This commit is contained in:
2026-02-04 15:56:09 +02:00
parent e392b449cc
commit 81e078e92a
17 changed files with 1486 additions and 414 deletions

View File

@@ -0,0 +1,130 @@
# RFCP 3.6.0 — Production GPU Build (Claude Code Task)
## Goal
Build `rfcp-server.exe` (PyInstaller) with CuPy GPU support so production RFCP
detects the NVIDIA GPU without manual `pip install`.
Currently production exe shows "CPU (NumPy)" because CuPy is not bundled.
## Current Environment (CONFIRMED WORKING)
```
Windows 10 (10.0.26200)
Python 3.11.8 (C:\Python311)
NVIDIA GeForce RTX 4060 Laptop GPU (8 GB VRAM)
CUDA Toolkit 13.1 (C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.1)
CUDA_PATH = C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.1
Packages:
cupy-cuda13x 13.6.0 ← NOT cuda12x!
numpy 1.26.4
scipy 1.17.0
fastrlock 0.8.3
pyinstaller 6.18.0
GPU compute verified:
python -c "import cupy; a = cupy.array([1,2,3]); print(a.sum())" → 6 ✅
```
## What We Already Tried (And Why It Failed)
### Attempt 1: ONEFILE spec with collect_all('cupy')
- `collect_all('cupy')` returns 1882 datas, **0 binaries** — CuPy pip doesn't bundle DLLs on Windows
- CUDA DLLs come from two separate sources:
- **nvidia pip packages** (14 DLLs in `C:\Python311\Lib\site-packages\nvidia\*/bin/`)
- **CUDA Toolkit** (13 DLLs in `C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.1\bin\x64\`)
- We manually collected these 27 DLLs in the spec
- Build succeeded (3 GB exe!) but crashed on launch:
```
[PYI-10456:ERROR] Failed to extract cufft64_12.dll: decompression resulted in return code -1!
```
- Root cause: `cufft64_12.dll` is 297 MB — PyInstaller's zlib compression fails on it in ONEFILE mode
### Attempt 2: We were about to try ONEDIR but haven't built it yet
### Key Insight: Duplicate DLLs from two sources
nvidia pip packages have CUDA 12.x DLLs (cublas64_12.dll etc.)
CUDA Toolkit 13.1 has CUDA 13.x DLLs (cublas64_13.dll etc.)
CuPy-cuda13x needs the 13.x versions. The 12.x from pip may conflict.
## What Needs To Happen
1. **Build rfcp-server as ONEDIR** (folder with exe + DLLs, not single exe)
- This avoids the decompression crash with large CUDA DLLs
- Output: `backend/dist/rfcp-server/rfcp-server.exe` + all DLLs alongside
2. **Include ONLY the correct CUDA DLLs**
- Prefer CUDA Toolkit 13.1 DLLs (match cupy-cuda13x)
- The nvidia pip packages have cuda12x DLLs — may cause version conflicts
- Key DLLs needed: cublas, cusparse, cusolver, curand, cufft, nvrtc, cudart
3. **Exclude bloat** — the previous build pulled in tensorflow, grpc, opentelemetry etc.
making it 3 GB. Real size should be ~600-800 MB.
4. **Test the built exe** — run it standalone and verify:
- `curl http://localhost:8090/api/health` returns `"build": "gpu"`
- `curl http://localhost:8090/api/gpu/status` returns `"available": true`
- Or at minimum: the exe starts without errors and CuPy imports successfully
5. **Update Electron integration** if needed:
- Current Electron expects a single `rfcp-server.exe` file
- With ONEDIR, it's a folder `rfcp-server/rfcp-server.exe`
- File: `desktop/main.js` or `desktop/src/main.ts` — look for where it spawns backend
- The path needs to change from `resources/backend/rfcp-server.exe`
to `resources/backend/rfcp-server/rfcp-server.exe`
## File Locations
```
D:\root\rfcp\
├── backend\
│ ├── run_server.py ← PyInstaller entry point
│ ├── app\
│ │ ├── main.py ← FastAPI app
│ │ ├── services\
│ │ │ ├── gpu_backend.py ← GPU detection (CuPy/NumPy fallback)
│ │ │ └── coverage_service.py ← Uses get_array_module()
│ │ └── api\routes\gpu.py ← /api/gpu/status, /api/gpu/diagnostics
│ ├── dist\ ← PyInstaller output goes here
│ └── build\ ← PyInstaller build cache
├── installer\
│ ├── rfcp-server-gpu.spec ← GPU spec (needs fixing)
│ ├── rfcp-server.spec ← CPU spec (working, don't touch)
│ ├── rfcp.ico ← Icon (exists)
│ └── build-gpu.bat ← Build script
├── desktop\
│ ├── main.js or src/main.ts ← Electron main process
│ └── resources\backend\ ← Where production exe lives
└── frontend\ ← React frontend (no changes needed)
```
## Existing CPU spec for reference
The working CPU-only spec is at `installer/rfcp-server.spec`. Use it as the base
and ADD CuPy + CUDA on top. Don't reinvent the wheel.
## Build Command
```powershell
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --clean --noconfirm
```
## Success Criteria
- [ ] `dist/rfcp-server/rfcp-server.exe` starts without errors
- [ ] CuPy imports successfully inside the exe (no missing DLL errors)
- [ ] `/api/gpu/status` returns `"available": true, "device": "RTX 4060"`
- [ ] Total folder size < 1 GB (ideally 600-800 MB)
- [ ] No tensorflow/grpc/opentelemetry bloat
- [ ] Electron can find and launch the backend (path updated if needed)
## Important Notes
- Do NOT use cupy-cuda12x — we migrated to cupy-cuda13x
- Do NOT try ONEFILE mode — cufft64_12.dll (297 MB) crashes decompression
- The nvidia pip packages (nvidia-cublas-cu12, etc.) are still installed but may
conflict with CUDA Toolkit 13.1 — prefer Toolkit DLLs
- `collect_all('cupy')` gives 0 binaries on Windows — DLLs must be manually specified
- gpu_backend.py already handles CuPy absence gracefully (falls back to NumPy)

View File

@@ -0,0 +1,133 @@
# RFCP 3.7.0 — GPU-Accelerated Coverage Calculations
## Context
Iteration 3.6.0 completed: CuPy-cuda13x works in production PyInstaller build,
RTX 4060 detected, ONEDIR build with CUDA DLLs. BUT coverage calculations still
run on CPU because coverage_service.py uses `import numpy as np` directly instead
of the GPU backend.
The GPU infrastructure is ready:
- `app/services/gpu_backend.py` has `GPUManager.get_array_module()` → returns cupy or numpy
- `/api/gpu/status` confirms `"active_backend": "cuda"`
- CuPy is imported and GPU detected in the frozen exe
## Goal
Replace direct `np.` calls in coverage_service.py with `xp = gpu_manager.get_array_module()`
so calculations run on GPU when available, with automatic NumPy fallback.
## Files to Modify
### `app/services/coverage_service.py`
**Line 7**: `import numpy as np` — keep this but also import gpu_manager
Add near top:
```python
from app.services.gpu_backend import gpu_manager
```
**Key sections to GPU-accelerate** (highest impact first):
#### 1. Grid array creation (lines 549-550, 922-923)
```python
# BEFORE:
grid_lats = np.array([lat for lat, lon in grid])
grid_lons = np.array([lon for lat, lon in grid])
# AFTER:
xp = gpu_manager.get_array_module()
grid_lats = xp.array([lat for lat, lon in grid])
grid_lons = xp.array([lon for lat, lon in grid])
```
#### 2. Trig calculations (line 468, 1031, 1408-1415, 1442)
These use np.cos, np.radians, np.sin, np.degrees, np.arctan2 — all have CuPy equivalents.
```python
# BEFORE:
lon_delta = settings.radius / (111000 * np.cos(np.radians(center_lat)))
cos_lat = np.cos(np.radians(center_lat))
# AFTER:
xp = gpu_manager.get_array_module()
lon_delta = settings.radius / (111000 * float(xp.cos(xp.radians(center_lat))))
cos_lat = float(xp.cos(xp.radians(center_lat)))
```
#### 3. The heavy calculation loop — `_run_point_loop` (line 1070) and `_calculate_point_sync` (line 1112)
This is where 90% of time is spent. Currently processes points one-by-one.
The GPU win comes from vectorizing the path loss calculation across ALL grid points at once.
**Strategy**: Instead of looping through points, create arrays of all distances/angles
and compute path loss for all points in one vectorized operation.
#### 4. `_calculate_bearing` (line 1402) — already vectorizable
```python
# All np.* functions here have direct CuPy equivalents
# Just replace np → xp
```
## Important Rules
1. **Always get xp at function scope**, not module scope:
```python
def my_function(self, ...):
xp = gpu_manager.get_array_module()
# use xp instead of np
```
2. **Convert GPU arrays back to CPU** before returning to non-GPU code:
```python
if hasattr(result, 'get'): # CuPy array
result = result.get() # → numpy array
```
3. **Keep np for small/scalar operations** — GPU overhead isn't worth it for single values.
Only use xp for array operations on 100+ elements.
4. **Don't break the fallback** — if CuPy isn't available, `get_array_module()` returns numpy,
so `xp.array()` etc. work identically.
5. **Test both paths** — run with GPU and verify same results as CPU.
## Testing
After changes:
```powershell
# Rebuild
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --noconfirm
# Run
.\dist\rfcp-server\rfcp-server.exe
# Test calculation via frontend — watch Task Manager GPU utilization
# Should see GPU Compute spike during coverage calculation
# Time should be significantly faster than 10s for 1254 points
```
Compare before/after:
- Current (CPU): ~10s for 1254 points, 5km radius
- Expected (GPU): 1-3s for same calculation
Also test GPU diagnostics:
```
curl http://localhost:8888/api/gpu/diagnostics
```
## What NOT to Change
- Don't modify gpu_backend.py — it's working correctly
- Don't change the API endpoints or response format
- Don't remove the NumPy import — keep it for non-array operations
- Don't change propagation model math — only the array operations
- Don't change _filter_buildings_to_bbox or OSM functions — they use lists not arrays
## Success Criteria
- [ ] Coverage calculation uses GPU (visible in Task Manager)
- [ ] Calculation time reduced for 1000+ point grids
- [ ] CPU fallback still works (test by setting active_backend to cpu via API)
- [ ] Same coverage results (heatmap should look identical)
- [ ] No regression in tiled processing mode

View File

@@ -0,0 +1,181 @@
# RFCP 3.8.0 — Vectorize Per-Point Coverage Calculations
## Context
Iteration 3.7.0 added GPU precompute for distances + base path loss (Phase 2.5).
But Phase 3 (per-point loop) still runs on CPU, one point at a time across workers.
This is where 95% of time goes on Full preset (195s for 6,642 points).
Current pipeline:
```
Phase 2.5 (GPU, 0.01s): distances + base path_loss → precomputed arrays
Phase 3 (CPU, 195s): per-point terrain_loss, building_loss, reflections, vegetation
```
Goal: Vectorize the heavy per-point calculations so GPU handles them in bulk.
## Architecture
The key insight: `_calculate_point_sync` (line ~1127) does these steps per point:
1. **Terrain LOS check** — get elevation profile between site and point, check clearance
2. **Diffraction loss** — knife-edge based on Fresnel zone clearance
3. **Building obstruction** — find buildings between site and point, calculate penetration loss
4. **Materials penalty** — add loss based on building material type
5. **Dominant path analysis** — LOS vs reflection vs diffraction
6. **Street canyon** — check if point is in urban canyon
7. **Reflections** — find reflection paths off buildings (most expensive!)
8. **Vegetation loss** — check vegetation between site and point
9. **Final RSRP** — tx_power - path_loss - terrain_loss - building_loss - veg_loss + gains
## Strategy: Vectorize in Stages
NOT everything can be vectorized equally. Prioritize by time spent:
### Stage 1: Terrain LOS + Diffraction (HIGH IMPACT)
Currently: For each point, sample ~50-100 elevation values along radial path,
find min clearance, compute knife-edge diffraction.
**Vectorize**: Create 2D elevation profiles for ALL points at once.
- All points share the same site location
- For N points, create N terrain profiles (each M samples)
- Compute Fresnel clearance for all profiles vectorized
- Compute diffraction loss vectorized
```python
# Instead of per-point:
for point in grid:
profile = get_terrain_profile(site, point, num_samples=50)
clearance = min_clearance(profile)
loss = diffraction_loss(clearance, freq)
# Vectorized:
xp = gpu_manager.get_array_module()
# all_profiles shape: (N_points, M_samples)
all_profiles = get_terrain_profiles_batch(site, all_points, num_samples=50)
all_clearances = compute_clearances_batch(all_profiles, site_elev, point_elevs, distances)
all_terrain_loss = diffraction_loss_batch(all_clearances, freq)
```
### Stage 2: Building Obstruction (HIGH IMPACT)
Currently: For each point, find nearby buildings, check if they obstruct path.
**Vectorize**: Use spatial indexing but batch the geometry checks.
- Pre-compute building bounding boxes as GPU arrays
- For each point, ray-building intersection can be done as matrix operation
- Building penetration loss is simple lookup after intersection
NOTE: This is harder to vectorize because each point has different number of
nearby buildings. Options:
a) Pad to max buildings per point (wastes memory but simple)
b) Use sparse representation
c) Keep per-point but use GPU for the geometry math
Recommend option (c) initially — keep the spatial query on CPU but move
the trig/geometry calculations to GPU.
### Stage 3: Reflections (MEDIUM IMPACT, only on Full preset)
Currently: For each point with buildings, compute reflection paths.
This is the most complex calculation and hardest to vectorize.
**Approach**: Keep reflections per-point for now, but optimize the inner math
with vectorized operations.
### Stage 4: Vegetation Loss (LOW IMPACT)
Simple lookup — not worth GPU overhead.
## Implementation Plan
### Step 1: Batch terrain profiling
Add to coverage_service.py a new method:
```python
def _batch_terrain_profiles(self, site_lat, site_lon, site_elev,
grid_lats, grid_lons, grid_elevs,
distances, frequency, num_samples=50):
"""Compute terrain LOS and diffraction loss for all points at once."""
xp = gpu_manager.get_array_module()
N = len(grid_lats)
# Interpolate terrain profiles for all points
# Each profile: site → point, num_samples elevation values
# Use terrain tile data directly
# Compute Fresnel zone clearance for each profile
# Compute knife-edge diffraction loss
return terrain_losses # shape (N,)
```
### Step 2: Batch building check
Add method:
```python
def _batch_building_obstruction(self, site_lat, site_lon,
grid_lats, grid_lons,
distances, buildings_spatial_index,
all_buildings):
"""Compute building loss for all points at once."""
# For each point, query spatial index (CPU)
# Batch the geometry intersection math (GPU)
# Return losses
return building_losses # shape (N,)
```
### Step 3: Replace _run_point_loop
Instead of ProcessPool workers, do:
```python
# In calculate_coverage, after Phase 2.5:
terrain_losses = self._batch_terrain_profiles(...)
building_losses = self._batch_building_obstruction(...)
# Final RSRP is now fully vectorized:
rsrp = tx_power - precomputed_path_loss - terrain_losses - building_losses - veg_losses
# + antenna_gains + reflection_gains
```
### Step 4: Keep worker fallback
If GPU not available or for very complex calculations (reflections),
fall back to the existing per-point ProcessPool approach.
## Important Notes
1. **GPU code only in main process** — learned from 3.7.0, never import gpu_manager in workers
2. **Terrain data access** — terrain tiles are in memory, need efficient sampling for batch profiles
3. **CuPy ↔ NumPy bridge** — use `xp.asnumpy()` or `.get()` to convert back to CPU
4. **Memory** — 6,642 points × 50 terrain samples = 332,100 floats = 2.5 MB on GPU, no problem
5. **Accuracy** — results must match existing per-point calculation within 1 dB
## Testing
```powershell
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --noconfirm
.\dist\rfcp-server\rfcp-server.exe
```
Compare Full preset:
- Before (3.7.0): ~195s for 6,642 points
- Target (3.8.0): <30s for same calculation
- Stretch goal: <10s
Verify accuracy:
- Run same location with GPU and CPU backend
- Compare RSRP values — should be within 1 dB
- Coverage percentages (Excellent/Good/Fair/Weak) should be very close
## What NOT to Change
- Don't modify propagation model math (Okumura-Hata, COST-231, Free-Space formulas)
- Don't change API endpoints or response format
- Don't remove the ProcessPool fallback — keep it for CPU-only mode
- Don't change OSM fetching or caching
- Don't modify the frontend
## Success Criteria
- [ ] Full preset completes in <30s (was 195s)
- [ ] Standard preset completes in <5s (was 7.2s)
- [ ] No CuPy errors in worker processes
- [ ] CPU fallback still works
- [ ] Results match within 1 dB accuracy
- [ ] GPU utilization visible in Task Manager during calculation

View File

@@ -0,0 +1,436 @@
# RFCP 3.9.0 — SRTM1 Real Terrain Data Integration
## Context
RFCP currently downloads terrain tiles from an elevation API at runtime.
This works but has limitations:
- Requires internet connection
- Unknown data source quality
- No offline capability (critical for tactical/field use)
- No control over resolution or caching
Goal: Replace with SRTM1 (30m resolution) HGT files, offline-first architecture.
## SRTM1 Data Format
HGT files are dead simple:
-×1° tiles, named by southwest corner: `N48E033.hgt`
- 3601×3601 grid of signed 16-bit integers (big-endian)
- Each value = elevation in meters
- File size: exactly 25,934,402 bytes (3601 × 3601 × 2)
- Row order: north to south (first row = northernmost)
- Column order: west to east
- Adjacent tiles overlap by 1 pixel on shared edges
- Void/no-data value: -32768
Compressed (.hgt.zip): ~10-15 MB per tile typically.
## Architecture
### Tile Storage Layout
```
{app_data}/terrain/
├── srtm1/ # 30m resolution tiles
│ ├── N48E033.hgt # Uncompressed for fast access
│ ├── N48E034.hgt
│ ├── N48E035.hgt
│ └── ...
├── tile_index.json # Metadata: available tiles, checksums, dates
└── downloads/ # Temporary download staging
```
On Windows, `{app_data}` = the application's data directory.
For PyInstaller exe: `data/terrain/` relative to exe location.
The path must be configurable (environment variable or config file).
### Tile Manager (new file: `terrain_manager.py`)
```python
class SRTMTileManager:
"""Manages SRTM1 HGT tile storage, loading, and caching."""
def __init__(self, terrain_dir: str):
self.terrain_dir = Path(terrain_dir)
self.srtm1_dir = self.terrain_dir / "srtm1"
self.srtm1_dir.mkdir(parents=True, exist_ok=True)
# In-memory cache: tile_name -> numpy array
self._tile_cache: Dict[str, np.ndarray] = {}
self._max_cache_tiles = 16 # ~16 tiles = ~400 MB RAM
def get_tile_name(self, lat: float, lon: float) -> str:
"""Convert lat/lon to SRTM tile name."""
# Floor to get southwest corner
lat_int = int(lat) if lat >= 0 else int(lat) - 1
lon_int = int(lon) if lon >= 0 else int(lon) - 1
lat_prefix = "N" if lat_int >= 0 else "S"
lon_prefix = "E" if lon_int >= 0 else "W"
return f"{lat_prefix}{abs(lat_int):02d}{lon_prefix}{abs(lon_int):03d}"
def get_required_tiles(self, center_lat, center_lon, radius_km) -> List[str]:
"""Determine which tiles are needed for a coverage calculation."""
# Calculate bounding box from center + radius
# Return list of tile names
def has_tile(self, tile_name: str) -> bool:
"""Check if tile exists locally."""
return (self.srtm1_dir / f"{tile_name}.hgt").exists()
def load_tile(self, tile_name: str) -> Optional[np.ndarray]:
"""Load tile from disk into memory. Returns 3601x3601 int16 array."""
if tile_name in self._tile_cache:
return self._tile_cache[tile_name]
hgt_path = self.srtm1_dir / f"{tile_name}.hgt"
if not hgt_path.exists():
return None
# Read raw HGT: big-endian signed 16-bit
data = np.fromfile(str(hgt_path), dtype='>i2')
tile = data.reshape((3601, 3601))
# Replace void values
tile = tile.astype(np.float32)
tile[tile == -32768] = np.nan
# Cache management (LRU-style: evict oldest if full)
if len(self._tile_cache) >= self._max_cache_tiles:
oldest_key = next(iter(self._tile_cache))
del self._tile_cache[oldest_key]
self._tile_cache[tile_name] = tile
return tile
def get_elevation(self, lat: float, lon: float) -> Optional[float]:
"""Get elevation at a single point with bilinear interpolation."""
tile_name = self.get_tile_name(lat, lon)
tile = self.load_tile(tile_name)
if tile is None:
return None
return self._bilinear_sample(tile, lat, lon)
def get_elevations_batch(self, lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
"""Get elevations for array of points. Vectorized."""
# Group points by tile
# Load needed tiles
# Vectorized bilinear interpolation per tile
# Return array of elevations
async def download_tile(self, tile_name: str) -> bool:
"""Download a single tile from remote source (if online)."""
# Try multiple sources in order:
# 1. Own server (future: UMTC sync endpoint)
# 2. srtm.fasma.org (no auth required)
# 3. viewfinderpanoramas.org (no auth, void-filled)
# Returns True if successful
def get_missing_tiles(self, center_lat, center_lon, radius_km) -> List[str]:
"""Check which needed tiles are not available locally."""
required = self.get_required_tiles(center_lat, center_lon, radius_km)
return [t for t in required if not self.has_tile(t)]
```
### Bilinear Interpolation (CRITICAL for accuracy)
Current system uses nearest-neighbor (pick closest grid cell).
SRTM1 at 30m means nearest-neighbor can have 15m positional error.
Bilinear interpolation reduces this to sub-meter accuracy.
```python
def _bilinear_sample(self, tile: np.ndarray, lat: float, lon: float) -> float:
"""Sample elevation with bilinear interpolation."""
# Tile southwest corner
lat_int = int(lat) if lat >= 0 else int(lat) - 1
lon_int = int(lon) if lon >= 0 else int(lon) - 1
# Fractional position within tile (0.0 to 1.0)
lat_frac = lat - lat_int # 0 = south edge, 1 = north edge
lon_frac = lon - lon_int # 0 = west edge, 1 = east edge
# Convert to row/col (note: rows go north to south!)
row_exact = (1.0 - lat_frac) * 3600.0 # 0 = north, 3600 = south
col_exact = lon_frac * 3600.0 # 0 = west, 3600 = east
# Four surrounding grid points
r0 = int(row_exact)
c0 = int(col_exact)
r1 = min(r0 + 1, 3600)
c1 = min(c0 + 1, 3600)
# Fractional position between grid points
dr = row_exact - r0
dc = col_exact - c0
# Bilinear interpolation
z00 = tile[r0, c0]
z01 = tile[r0, c1]
z10 = tile[r1, c0]
z11 = tile[r1, c1]
# Handle NaN (void) values
if np.isnan(z00) or np.isnan(z01) or np.isnan(z10) or np.isnan(z11):
# Fall back to nearest non-NaN
valid = [(z00, 0, 0), (z01, 0, 1), (z10, 1, 0), (z11, 1, 1)]
valid = [(z, r, c) for z, r, c in valid if not np.isnan(z)]
return valid[0][0] if valid else 0.0
elevation = (z00 * (1 - dr) * (1 - dc) +
z01 * (1 - dr) * dc +
z10 * dr * (1 - dc) +
z11 * dr * dc)
return float(elevation)
```
### Vectorized Batch Elevation (for GPU pipeline)
This replaces the current `_batch_elevation_lookup` in gpu_service.py.
Must handle multi-tile seamlessly.
```python
def get_elevations_batch(self, lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
"""Vectorized elevation lookup with bilinear interpolation.
Handles points spanning multiple tiles efficiently.
Groups points by tile, processes each tile with full NumPy vectorization.
"""
elevations = np.zeros(len(lats), dtype=np.float32)
# Compute tile indices for each point
lat_ints = np.where(lats >= 0, np.floor(lats).astype(int),
np.floor(lats).astype(int))
lon_ints = np.where(lons >= 0, np.floor(lons).astype(int),
np.floor(lons).astype(int))
# Group by tile
tile_keys = lat_ints * 1000 + lon_ints # unique key per tile
unique_keys = np.unique(tile_keys)
for key in unique_keys:
mask = tile_keys == key
lat_int = int(key // 1000)
lon_int = int(key % 1000)
if lon_int > 500: # handle negative longitudes
lon_int -= 1000
tile_name = self._make_tile_name(lat_int, lon_int)
tile = self.load_tile(tile_name)
if tile is None:
elevations[mask] = 0.0 # no data
continue
# Vectorized bilinear for all points in this tile
tile_lats = lats[mask]
tile_lons = lons[mask]
lat_frac = tile_lats - lat_int
lon_frac = tile_lons - lon_int
row_exact = (1.0 - lat_frac) * 3600.0
col_exact = lon_frac * 3600.0
r0 = np.clip(row_exact.astype(int), 0, 3599)
c0 = np.clip(col_exact.astype(int), 0, 3599)
r1 = np.clip(r0 + 1, 0, 3600)
c1 = np.clip(c0 + 1, 0, 3600)
dr = row_exact - r0
dc = col_exact - c0
z00 = tile[r0, c0]
z01 = tile[r0, c1]
z10 = tile[r1, c0]
z11 = tile[r1, c1]
result = (z00 * (1 - dr) * (1 - dc) +
z01 * (1 - dr) * dc +
z10 * dr * (1 - dc) +
z11 * dr * dc)
# Handle NaN voids
nan_mask = np.isnan(result)
if nan_mask.any():
result[nan_mask] = 0.0
elevations[mask] = result
return elevations
```
## Integration Points
### 1. Replace terrain_service.py elevation lookup
Current terrain service downloads elevation data from an API.
Replace with SRTMTileManager calls:
```python
# OLD:
elevation = await self.terrain_service.get_elevation(lat, lon)
# NEW:
elevation = self.tile_manager.get_elevation(lat, lon)
# Or for batch (GPU pipeline Phase 2.6):
elevations = self.tile_manager.get_elevations_batch(lats_array, lons_array)
```
### 2. Replace _batch_elevation_lookup in gpu_service.py
The vectorized elevation lookup in gpu_service.py currently loads tiles
and does nearest-neighbor sampling. Replace with tile_manager.get_elevations_batch()
which does bilinear interpolation.
### 3. Coverage service pre-check
Before starting calculation, check if all needed tiles are available:
```python
missing = self.tile_manager.get_missing_tiles(site_lat, site_lon, radius_km)
if missing:
if has_internet:
# Try to download missing tiles
for tile_name in missing:
await self.tile_manager.download_tile(tile_name)
else:
# Return warning to frontend
return {"warning": f"Missing terrain tiles: {missing}. Using flat terrain."}
```
### 4. Frontend notification
When tiles are missing, show a warning banner:
"⚠ Terrain data not available for this area. Coverage accuracy reduced."
When tiles are being downloaded:
"⬇ Downloading terrain data... (N48E033.hgt, 12.5 MB)"
### 5. Terrain Profile Viewer
The terrain profile viewer should use the same tile_manager
for consistent elevation data. With bilinear interpolation,
profiles will be much smoother and more accurate.
## Download Sources (Priority Order)
For auto-download when online:
1. **srtm.fasma.org** (no auth, direct HGT.zip download)
URL: `https://srtm.fasma.org/N48E033.SRTMGL1.hgt.zip`
- Free, no registration
- SRTM1 (30m) data
- May be slow or unreliable
2. **viewfinderpanoramas.org** (no auth, void-filled data)
URL: `http://viewfinderpanoramas.org/dem1/{region}/{tile}.hgt.zip`
- Free, no registration
- Void areas filled from topographic maps
- Better quality in mountainous areas
- File naming might differ by region
3. **Future: UMTC sync server**
URL: `https://rfcp.{your-domain}/api/terrain/tiles/{tile_name}.hgt`
- Self-hosted on your infrastructure
- Accessible via WireGuard mesh
- Can pre-populate with full Ukraine dataset
## Offline Bundle Strategy
For installer / field deployment:
### Option A: Region packs
Pre-package tiles by operational area:
- `terrain-dnipro.zip` — 4 tiles around Dnipro area (~100 MB)
- `terrain-ukraine-east.zip` — ~50 tiles, eastern Ukraine (~1.2 GB)
- `terrain-ukraine-full.zip` — ~171 tiles, all Ukraine (~4.3 GB)
### Option B: On-demand with cache
Ship empty, download tiles as needed on first calculation.
Cache permanently. Works well for development/testing.
### Option C: Live USB bundle
For tactical deployment, include full Ukraine terrain data
on the live USB alongside the application. 4.3 GB is acceptable
for a USB drive.
Recommend: **Option B for now** (development), **Option C for deployment**.
## File Changes
### New Files
- `backend/app/services/terrain_manager.py` — SRTMTileManager class
### Modified Files
- `backend/app/services/terrain_service.py` — Replace API calls with tile_manager
- `backend/app/services/gpu_service.py` — Replace _batch_elevation_lookup
- `backend/app/services/coverage_service.py` — Add missing tile pre-check
- `backend/app/main.py` — Initialize tile_manager on startup
### Config
- Add `TERRAIN_DIR` environment variable / config option
- Default: `./data/terrain` relative to backend exe
## Testing
```powershell
# Build and test
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --noconfirm
.\dist\rfcp-server\rfcp-server.exe
```
### Test 1: First run (no tiles cached)
- Start app, trigger calculation
- Should attempt to download required tile(s)
- If online: downloads, caches, calculates
- If offline: warning, flat terrain fallback
### Test 2: Cached tiles
- Run same calculation again
- Tile loaded from disk cache, no download
- Should be fast (tile load from disk < 100ms)
### Test 3: Accuracy comparison
- Compare elevation at known points (e.g., Dnipro city center)
- Cross-reference with Google Earth elevation
- Expected accuracy: ±5m horizontal, ±16m vertical (SRTM spec)
### Test 4: Multi-tile calculation
- Set radius to 50km+ to span multiple tiles
- Verify seamless stitching at tile boundaries
- No elevation jumps or artifacts at edges
### Test 5: Terrain profile
- Draw terrain profile across tile boundary
- Should be smooth, no discontinuity
- Compare with Google Earth profile for same path
### Test 6: Performance
- Tile load time from disk: <100ms
- Batch elevation lookup (6000 points): <50ms
- Should not regress overall calculation time
- Memory: ~25 MB per loaded tile, max 16 tiles = 400 MB
## What NOT to Change
- Don't modify GPU pipeline architecture (Phase 2.5/2.6/2.7)
- Don't change propagation model math
- Don't change API endpoints or response format
- Don't change frontend map or heatmap rendering
- Don't change OSM building/vegetation fetching
- Don't change PyInstaller build process (just add data dir)
## Success Criteria
- [ ] SRTM1 tiles load correctly (3601×3601, 30m resolution)
- [ ] Bilinear interpolation working (smoother than nearest-neighbor)
- [ ] Offline mode works with pre-cached tiles
- [ ] Auto-download works when online
- [ ] Missing tile warning shown to user
- [ ] Multi-tile seamless stitching
- [ ] Terrain profile accuracy matches Google Earth within 20m
- [ ] No performance regression (calculation time same or faster)
- [ ] Tile cache directory configurable

View File

@@ -0,0 +1,656 @@
# RFCP Dependencies & Installer Specification
## Overview
All dependencies needed for RFCP to work out of the box, including GPU acceleration.
The installer must handle everything — user should NOT need to run pip manually.
---
## Python Dependencies
### Core (MUST have)
```txt
# requirements.txt
# Web framework
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
websockets>=12.0
# Scientific computing
numpy>=1.24.0
scipy>=1.11.0
# Geospatial
pyproj>=3.6.0 # coordinate transformations
shapely>=2.0.0 # geometry operations (boundary contours)
# Terrain data
rasterio>=1.3.0 # GeoTIFF reading (optional, for custom terrain)
# Note: SRTM .hgt files read with numpy directly
# OSM data
requests>=2.31.0 # HTTP client for OSM Overpass API
geopy>=2.4.0 # distance calculations
# Database
# sqlite3 is built-in Python — no install needed
# Utilities
orjson>=3.9.0 # fast JSON (optional, faster API responses)
pydantic>=2.0.0 # data validation (FastAPI dependency)
```
### GPU Acceleration (OPTIONAL — auto-detected)
```txt
# requirements-gpu-nvidia.txt
cupy-cuda12x>=12.0.0 # For CUDA 12.x (RTX 30xx, 40xx)
# OR
cupy-cuda11x>=11.0.0 # For CUDA 11.x (older cards)
# requirements-gpu-opencl.txt
pyopencl>=2023.1 # For ANY GPU (Intel, AMD, NVIDIA)
```
### Development / Testing
```txt
# requirements-dev.txt
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.25.0 # async test client
```
---
## System Dependencies
### NVIDIA GPU Support
```
REQUIRED: NVIDIA Driver (comes with GPU)
REQUIRED: CUDA Toolkit 12.x (for CuPy)
Check if installed:
nvidia-smi → shows driver version
nvcc --version → shows CUDA toolkit version
If missing CUDA toolkit:
Download from: https://developer.nvidia.com/cuda-downloads
Select: Windows > x86_64 > 11/10 > exe (local)
Size: ~3 GB
Alternative: cupy auto-installs CUDA runtime!
pip install cupy-cuda12x
This bundles CUDA runtime (~700 MB) — no separate install needed
```
### Intel GPU Support (OpenCL)
```
REQUIRED: Intel GPU Driver (usually pre-installed)
REQUIRED: Intel OpenCL Runtime
Check if installed:
Open Device Manager → Display Adapters → Intel UHD/Iris
For OpenCL:
Download Intel GPU Computing Runtime:
https://github.com/intel/compute-runtime/releases
Or: Intel oneAPI Base Toolkit (includes OpenCL)
https://www.intel.com/content/www/us/en/developer/tools/oneapi/base-toolkit-download.html
```
### AMD GPU Support (OpenCL)
```
REQUIRED: AMD Adrenalin Driver (includes OpenCL)
Download from: https://www.amd.com/en/support
```
---
## Node.js / Frontend Dependencies
### System Requirements
```
Node.js >= 18.0.0 (LTS recommended)
npm >= 9.0.0
Check:
node --version
npm --version
```
### Frontend packages (managed by npm)
```json
// package.json — key dependencies
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.0",
"recharts": "^2.8.0",
"zustand": "^4.4.0",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"vite": "^5.0.0",
"typescript": "^5.3.0",
"tailwindcss": "^3.4.0",
"@types/leaflet": "^1.9.0"
}
}
```
---
## Installer Script
### Windows Installer (NSIS or Electron-Builder)
```python
# install_rfcp.py — Python-based installer/setup script
import subprocess
import sys
import platform
import os
import shutil
import json
def check_python():
"""Verify Python 3.10+ is available."""
version = sys.version_info
if version.major < 3 or version.minor < 10:
print(f"❌ Python 3.10+ required, found {version.major}.{version.minor}")
return False
print(f"✅ Python {version.major}.{version.minor}.{version.micro}")
return True
def check_node():
"""Verify Node.js 18+ is available."""
try:
result = subprocess.run(["node", "--version"], capture_output=True, text=True)
version = result.stdout.strip().lstrip('v')
major = int(version.split('.')[0])
if major < 18:
print(f"❌ Node.js 18+ required, found {version}")
return False
print(f"✅ Node.js {version}")
return True
except FileNotFoundError:
print("❌ Node.js not found")
return False
def detect_gpu():
"""Detect available GPU hardware."""
gpus = {
"nvidia": False,
"nvidia_name": "",
"intel": False,
"intel_name": "",
"amd": False,
"amd_name": ""
}
# Check NVIDIA
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=name,driver_version,memory.total",
"--format=csv,noheader"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
info = result.stdout.strip()
gpus["nvidia"] = True
gpus["nvidia_name"] = info.split(",")[0].strip()
print(f"✅ NVIDIA GPU: {info}")
except (FileNotFoundError, subprocess.TimeoutExpired):
print(" No NVIDIA GPU detected")
# Check Intel/AMD via WMI (Windows)
if platform.system() == "Windows":
try:
result = subprocess.run(
["wmic", "path", "win32_videocontroller", "get",
"name,adapterram,driverversion", "/format:csv"],
capture_output=True, text=True, timeout=5
)
for line in result.stdout.strip().split('\n'):
if 'Intel' in line:
gpus["intel"] = True
gpus["intel_name"] = [x for x in line.split(',') if 'Intel' in x][0]
print(f"✅ Intel GPU: {gpus['intel_name']}")
elif 'AMD' in line or 'Radeon' in line:
gpus["amd"] = True
gpus["amd_name"] = [x for x in line.split(',') if 'AMD' in x or 'Radeon' in x][0]
print(f"✅ AMD GPU: {gpus['amd_name']}")
except Exception:
pass
return gpus
def install_core_dependencies():
"""Install core Python dependencies."""
print("\n📦 Installing core dependencies...")
subprocess.run([
sys.executable, "-m", "pip", "install", "-r", "requirements.txt",
"--quiet", "--no-warn-script-location"
], check=True)
print("✅ Core dependencies installed")
def install_gpu_dependencies(gpus: dict):
"""Install GPU-specific dependencies based on detected hardware."""
print("\n🎮 Setting up GPU acceleration...")
gpu_installed = False
# NVIDIA — install CuPy (includes CUDA runtime)
if gpus["nvidia"]:
print(f" Installing CuPy for {gpus['nvidia_name']}...")
try:
# Try CUDA 12 first (newer cards)
subprocess.run([
sys.executable, "-m", "pip", "install", "cupy-cuda12x",
"--quiet", "--no-warn-script-location"
], check=True, timeout=300)
print(f" ✅ CuPy (CUDA 12) installed for {gpus['nvidia_name']}")
gpu_installed = True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
try:
# Fallback to CUDA 11
subprocess.run([
sys.executable, "-m", "pip", "install", "cupy-cuda11x",
"--quiet", "--no-warn-script-location"
], check=True, timeout=300)
print(f" ✅ CuPy (CUDA 11) installed for {gpus['nvidia_name']}")
gpu_installed = True
except Exception as e:
print(f" ⚠️ CuPy installation failed: {e}")
print(f" 💡 Manual install: pip install cupy-cuda12x")
# Intel/AMD — install PyOpenCL
if gpus["intel"] or gpus["amd"]:
gpu_name = gpus["intel_name"] or gpus["amd_name"]
print(f" Installing PyOpenCL for {gpu_name}...")
try:
subprocess.run([
sys.executable, "-m", "pip", "install", "pyopencl",
"--quiet", "--no-warn-script-location"
], check=True, timeout=120)
print(f" ✅ PyOpenCL installed for {gpu_name}")
gpu_installed = True
except Exception as e:
print(f" ⚠️ PyOpenCL installation failed: {e}")
print(f" 💡 Manual install: pip install pyopencl")
if not gpu_installed:
print(" No GPU acceleration available — using CPU (NumPy)")
print(" 💡 This is fine! GPU just makes large calculations faster.")
return gpu_installed
def install_frontend():
"""Install frontend dependencies and build."""
print("\n🌐 Setting up frontend...")
frontend_dir = os.path.join(os.path.dirname(__file__), "frontend")
if os.path.exists(os.path.join(frontend_dir, "package.json")):
subprocess.run(["npm", "install"], cwd=frontend_dir, check=True)
subprocess.run(["npm", "run", "build"], cwd=frontend_dir, check=True)
print("✅ Frontend built")
else:
print("⚠️ Frontend directory not found")
def download_terrain_data():
"""Pre-download SRTM terrain tiles for Ukraine."""
print("\n🏔️ Checking terrain data...")
cache_dir = os.path.expanduser("~/.rfcp/terrain")
os.makedirs(cache_dir, exist_ok=True)
# Ukraine bounding box: lat 44-53, lon 22-41
# SRTM tiles needed for typical use
required_tiles = [
# Lviv oblast area (common test area)
"N49E025", "N49E024", "N49E026",
"N50E025", "N50E024", "N50E026",
# Dnipro area
"N48E034", "N48E035",
"N49E034", "N49E035",
]
existing = [f.replace(".hgt", "") for f in os.listdir(cache_dir) if f.endswith(".hgt")]
missing = [t for t in required_tiles if t not in existing]
if missing:
print(f" {len(missing)} terrain tiles needed (auto-download on first use)")
else:
print(f"{len(existing)} terrain tiles cached")
def create_launcher():
"""Create desktop shortcut / launcher script."""
print("\n🚀 Creating launcher...")
if platform.system() == "Windows":
# Create .bat launcher
launcher = os.path.join(os.path.dirname(__file__), "RFCP.bat")
with open(launcher, 'w') as f:
f.write('@echo off\n')
f.write('title RFCP - RF Coverage Planner\n')
f.write('echo Starting RFCP...\n')
f.write(f'cd /d "{os.path.dirname(__file__)}"\n')
f.write(f'"{sys.executable}" -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888\n')
print(f" ✅ Launcher created: {launcher}")
return True
def verify_installation():
"""Run quick verification tests."""
print("\n🔍 Verifying installation...")
checks = []
# Check core imports
try:
import numpy as np
checks.append(f"✅ NumPy {np.__version__}")
except ImportError:
checks.append("❌ NumPy missing")
try:
import scipy
checks.append(f"✅ SciPy {scipy.__version__}")
except ImportError:
checks.append("❌ SciPy missing")
try:
import fastapi
checks.append(f"✅ FastAPI {fastapi.__version__}")
except ImportError:
checks.append("❌ FastAPI missing")
try:
import shapely
checks.append(f"✅ Shapely {shapely.__version__}")
except ImportError:
checks.append("⚠️ Shapely missing (boundary features disabled)")
# Check GPU
try:
import cupy as cp
device = cp.cuda.Device(0)
checks.append(f"✅ CuPy → {device.name} ({device.mem_info[1]//1024//1024} MB)")
except ImportError:
checks.append(" CuPy not available")
except Exception as e:
checks.append(f"⚠️ CuPy error: {e}")
try:
import pyopencl as cl
devices = []
for p in cl.get_platforms():
for d in p.get_devices():
devices.append(d.name)
checks.append(f"✅ PyOpenCL → {', '.join(devices)}")
except ImportError:
checks.append(" PyOpenCL not available")
except Exception as e:
checks.append(f"⚠️ PyOpenCL error: {e}")
for check in checks:
print(f" {check}")
return all("" not in c for c in checks)
def main():
"""Main installer entry point."""
print("=" * 60)
print(" RFCP — RF Coverage Planner — Installer")
print("=" * 60)
print()
# Step 1: Check prerequisites
print("📋 Checking prerequisites...")
if not check_python():
sys.exit(1)
check_node()
# Step 2: Detect GPU
gpus = detect_gpu()
# Step 3: Install dependencies
install_core_dependencies()
install_gpu_dependencies(gpus)
# Step 4: Frontend
install_frontend()
# Step 5: Terrain data
download_terrain_data()
# Step 6: Launcher
create_launcher()
# Step 7: Verify
print()
success = verify_installation()
# Summary
print()
print("=" * 60)
if success:
print(" ✅ RFCP installed successfully!")
print()
print(" To start RFCP:")
print(" python -m uvicorn backend.app.main:app --port 8888")
print(" Then open: http://localhost:8888")
print()
if gpus["nvidia"]:
print(f" 🎮 GPU: {gpus['nvidia_name']} (CUDA)")
elif gpus["intel"] or gpus["amd"]:
gpu_name = gpus["intel_name"] or gpus["amd_name"]
print(f" 🎮 GPU: {gpu_name} (OpenCL)")
else:
print(" 💻 Mode: CPU only")
else:
print(" ⚠️ Installation completed with warnings")
print(" Some features may be limited")
print("=" * 60)
if __name__ == "__main__":
main()
```
---
## Electron-Builder / NSIS Packaging
### For .exe Installer
```yaml
# electron-builder.yml
appId: com.rfcp.coverage-planner
productName: "RFCP - RF Coverage Planner"
copyright: "RFCP 2026"
directories:
output: dist
buildResources: build
files:
- "backend/**/*"
- "frontend/dist/**/*"
- "requirements.txt"
- "install_rfcp.py"
- "!**/*.pyc"
- "!**/node_modules/**"
- "!**/venv/**"
extraResources:
- from: "python-embedded/"
to: "python/"
- from: "terrain-data/"
to: "terrain/"
win:
target:
- target: nsis
arch: [x64]
icon: "build/icon.ico"
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
installerIcon: "build/icon.ico"
license: "LICENSE.md"
# Custom NSIS script for GPU detection
include: "build/gpu-detect.nsh"
# Install steps:
# 1. Extract files
# 2. Run install_rfcp.py (detects GPU, installs deps)
# 3. Create Start Menu shortcuts
# 4. Create Desktop shortcut
```
### Portable Version (.zip)
```
RFCP-Portable/
├── RFCP.bat # Main launcher
├── install.bat # First-time setup
├── backend/
│ ├── app/
│ │ ├── main.py
│ │ ├── api/
│ │ ├── services/
│ │ └── models/
│ └── requirements.txt
├── frontend/
│ └── dist/ # Pre-built frontend
├── python/ # Embedded Python (optional)
│ ├── python.exe
│ └── Lib/
├── terrain/ # Pre-cached .hgt files
│ ├── N49E025.hgt
│ └── ...
├── data/
│ ├── osm_cache.db # SQLite cache (created on first run)
│ └── config.json # User settings
└── README.md
```
### install.bat (First-Time Setup)
```batch
@echo off
title RFCP - First Time Setup
echo ============================================
echo RFCP - RF Coverage Planner - Setup
echo ============================================
echo.
REM Check if Python exists
python --version >nul 2>&1
if errorlevel 1 (
echo ERROR: Python not found!
echo Please install Python 3.10+ from python.org
pause
exit /b 1
)
REM Run installer
python install_rfcp.py
echo.
echo Setup complete! Run RFCP.bat to start.
pause
```
### RFCP.bat (Launcher)
```batch
@echo off
title RFCP - RF Coverage Planner
cd /d "%~dp0"
REM Check if installed
if not exist "backend\app\main.py" (
echo ERROR: RFCP not found. Run install.bat first.
pause
exit /b 1
)
echo Starting RFCP...
echo Open http://localhost:8888 in your browser
echo Press Ctrl+C to stop
echo.
python -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8888
```
---
## Dependency Size Estimates
| Component | Size |
|-----------|------|
| Python (embedded) | ~30 MB |
| Core pip packages | ~80 MB |
| CuPy + CUDA runtime | ~700 MB |
| PyOpenCL | ~15 MB |
| Frontend (built) | ~5 MB |
| SRTM terrain (Ukraine) | ~300 MB |
| **Total (with CUDA)** | **~1.1 GB** |
| **Total (CPU only)** | **~415 MB** |
---
## Runtime Requirements
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| RAM | 4 GB | 8+ GB |
| Disk | 500 MB | 2 GB (with terrain cache) |
| CPU | 4 cores | 8+ cores |
| GPU | - | NVIDIA GTX 1060+ / Intel UHD 630+ |
| OS | Windows 10 | Windows 10/11 64-bit |
| Python | 3.10 | 3.11+ |
| Node.js | 18 | 20 LTS |
---
## Auto-Update Mechanism (Future)
```python
# Check for updates on startup
async def check_for_updates():
try:
response = await httpx.get(
"https://api.github.com/repos/user/rfcp/releases/latest",
timeout=5
)
latest = response.json()["tag_name"]
current = get_current_version()
if latest != current:
return {
"update_available": True,
"current": current,
"latest": latest,
"download_url": response.json()["assets"][0]["browser_download_url"]
}
except:
pass
return {"update_available": False}
```

View File

@@ -0,0 +1,557 @@
# 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"*

View File

@@ -0,0 +1,504 @@
# RFCP — Iteration 3.5.2: Native Backend + GPU Fix + UI Polish
## Overview
Fix critical architecture issues: GPU indicator dropdown broken, GPU acceleration not working
(CuPy in wrong Python environment), and prepare path to remove WSL2 dependency for end users.
Plus UI polish items carried over from 3.5.1.
**Priority:** GPU fixes first, then UI polish, then native Windows exploration.
---
## CRITICAL CONTEXT
### Current Architecture Problem
```
RFCP.exe (Electron, Windows)
└── launches backend via WSL2:
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8090
└── /usr/bin/python3 (WSL2 system Python 3.12)
└── NO venv, NO CuPy installed
User installed CuPy in Windows Python → backend doesn't see it.
User installed CuPy in WSL system Python → needs --break-system-packages
```
### GPU Hardware (Confirmed Working)
```
nvidia-smi output (from WSL2):
NVIDIA GeForce RTX 4060 Laptop GPU
Driver: 581.42 (Windows) / 580.95.02 (WSL2)
CUDA: 13.0
VRAM: 8188 MiB
GPU passthrough: WORKING ✅
```
### Files to Reference
```
backend/app/services/gpu_backend.py — GPUManager class
backend/app/api/routes/gpu.py — GPU API endpoints
frontend/src/components/ui/GPUIndicator.tsx — GPU badge/dropdown
desktop/ — Electron app source
installer/ — Build scripts
```
---
## Task 1: Fix GPU Indicator Dropdown Z-Index (Priority 1 — 10 min)
### Problem
GPU dropdown WORKS (opens on click, shows diagnostics, install hints) but renders
BEHIND the right sidebar panel. The sidebar (Sites, Coverage Settings) has higher
z-index than the GPU dropdown, so the dropdown is invisible/hidden underneath.
See screenshots: dropdown is partially visible only when sidebar is made very narrow.
It shows: "COMPUTE DEVICES", "CPU (NumPy)", install hints, "Run Diagnostics",
and even diagnostics JSON — all working but hidden behind sidebar.
### Root Cause
GPUIndicator dropdown z-index is lower than the right sidebar panel z-index.
### Solution
In `GPUIndicator.tsx` — find the dropdown container div and set z-index
higher than the sidebar:
```tsx
{isOpen && (
<div
className="absolute top-full mt-1 bg-dark-surface border border-dark-border
rounded-lg shadow-2xl p-3 min-w-[300px]"
style={{ zIndex: 9999 }} // MUST be above sidebar (which is ~z-50 or z-auto)
>
...
</div>
)}
```
**Key requirements:**
1. `z-index: 9999` (or at minimum higher than sidebar)
2. Position: dropdown should open to the LEFT (toward center of screen)
to avoid being cut off by right edge
3. `right-0` on the absolute positioning (anchored to right edge of badge)
**Alternative approach** — use Tailwind z-index:
```tsx
className="absolute top-full right-0 mt-1 z-[9999] ..."
```
**Also check:** The parent container of GPUIndicator might need `position: relative`
for absolute positioning to work correctly against the right sidebar.
### Testing
- [ ] Click "CPU" badge → dropdown appears ABOVE the sidebar
- [ ] Full dropdown visible: devices, install hints, diagnostics
- [ ] Dropdown doesn't get cut off on right side
- [ ] Click outside → dropdown closes
- [ ] Dropdown works at any window width
---
## Task 2: Install CuPy in WSL Backend (Priority 1 — 10 min)
### Problem
CuPy installed in Windows Python, but backend runs in WSL2 system Python.
### Solution
Add a startup check in the backend that detects missing GPU packages
and provides clear instructions. Also, the Electron app should try to
install dependencies on first launch.
**Step 1: Backend startup GPU check**
In `backend/app/main.py`, add on startup:
```python
@app.on_event("startup")
async def check_gpu_availability():
"""Log GPU status on startup for debugging."""
import logging
logger = logging.getLogger("rfcp.gpu")
# Check CuPy
try:
import cupy as cp
device_count = cp.cuda.runtime.getDeviceCount()
if device_count > 0:
name = cp.cuda.Device(0).name
mem = cp.cuda.Device(0).mem_info[1] // 1024 // 1024
logger.info(f"✅ GPU detected: {name} ({mem} MB VRAM)")
logger.info(f" CuPy {cp.__version__}, CUDA devices: {device_count}")
else:
logger.warning("⚠️ CuPy installed but no CUDA devices found")
except ImportError:
logger.warning("⚠️ CuPy not installed — GPU acceleration disabled")
logger.warning(" Install: pip install cupy-cuda12x --break-system-packages")
except Exception as e:
logger.warning(f"⚠️ CuPy error: {e}")
# Check PyOpenCL
try:
import pyopencl as cl
platforms = cl.get_platforms()
for p in platforms:
for d in p.get_devices():
logger.info(f"✅ OpenCL device: {d.name.strip()}")
except ImportError:
logger.info(" PyOpenCL not installed (optional)")
except Exception:
pass
```
**Step 2: GPU diagnostics endpoint enhancement**
Enhance `/api/gpu/diagnostics` to return install commands:
```python
@router.get("/diagnostics")
async def gpu_diagnostics():
import platform, sys
diagnostics = {
"python": sys.version,
"platform": platform.platform(),
"executable": sys.executable,
"is_wsl": "microsoft" in platform.release().lower(),
"cuda_available": False,
"opencl_available": False,
"install_hint": "",
"devices": []
}
# Check nvidia-smi
try:
import subprocess
result = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
diagnostics["nvidia_smi"] = result.stdout.strip()
except:
diagnostics["nvidia_smi"] = "not found"
# Check CuPy
try:
import cupy
diagnostics["cupy_version"] = cupy.__version__
diagnostics["cuda_available"] = True
count = cupy.cuda.runtime.getDeviceCount()
for i in range(count):
d = cupy.cuda.Device(i)
diagnostics["devices"].append({
"id": i,
"name": d.name,
"memory_mb": d.mem_info[1] // 1024 // 1024,
"backend": "CUDA"
})
except ImportError:
if diagnostics.get("is_wsl"):
diagnostics["install_hint"] = "pip3 install cupy-cuda12x --break-system-packages"
else:
diagnostics["install_hint"] = "pip install cupy-cuda12x"
return diagnostics
```
**Step 3: Frontend shows diagnostics clearly**
In GPUIndicator dropdown, show:
```
⚠ No GPU detected
Your system: WSL2 + NVIDIA RTX 4060
To enable GPU acceleration:
┌─────────────────────────────────────────────┐
│ pip3 install cupy-cuda12x │
│ --break-system-packages │
└─────────────────────────────────────────────┘
Then restart RFCP.
[Copy Command] [Run Diagnostics]
```
### Testing
- [ ] Backend startup logs GPU status
- [ ] /api/gpu/diagnostics returns WSL detection + install hint
- [ ] Frontend shows clear install instructions
- [ ] After installing CuPy in WSL + restart → GPU appears in list
---
## Task 3: Terrain Profile Click Fix (Priority 2 — 5 min)
### Problem
Clicking "Terrain Profile" button in ruler measurement also adds a point on the map.
### Solution
In the Terrain Profile button handler:
```tsx
const handleTerrainProfile = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// ... open terrain profile
};
```
Also check if the button is rendered inside a map click handler area —
may need `L.DomEvent.disableClickPropagation(container)` on the parent.
### Testing
- [ ] Click "Terrain Profile" → opens profile, NO new ruler point added
- [ ] Map click still works normally when not clicking the button
---
## Task 4: Coverage Boundary — Real Contour Shape (Priority 2 — 45 min)
### Problem
Current boundary is a rough circle/ellipse. Should follow actual coverage contour.
### Approaches
**Option A: Shapely Alpha Shape (recommended)**
```python
# backend/app/services/boundary_service.py
from shapely.geometry import MultiPoint
from shapely.ops import unary_union
import numpy as np
def calculate_coverage_boundary(points: list, threshold_dbm: float = -100) -> list:
"""Calculate concave hull of coverage area above threshold."""
# Filter points above threshold
valid = [(p['lon'], p['lat']) for p in points if p['rsrp'] >= threshold_dbm]
if len(valid) < 3:
return []
mp = MultiPoint(valid)
# Use convex hull first, then try concave
try:
# Shapely 2.0+ has concave_hull
from shapely import concave_hull
hull = concave_hull(mp, ratio=0.3)
except ImportError:
# Fallback to convex hull
hull = mp.convex_hull
# Simplify to reduce points (0.001 deg ≈ 100m)
simplified = hull.simplify(0.001, preserve_topology=True)
# Extract coordinates
if simplified.geom_type == 'Polygon':
coords = list(simplified.exterior.coords)
return [{'lat': c[1], 'lon': c[0]} for c in coords]
return []
```
**Option B: Grid-based contour (simpler)**
```python
def grid_contour_boundary(points: list, threshold_dbm: float, resolution: float):
"""Find boundary by detecting edge cells in grid."""
# Create binary grid: 1 = above threshold, 0 = below
# Find cells where 1 is adjacent to 0 = boundary
# Convert cell coords back to lat/lon
# Return ordered boundary points
```
### API Endpoint
```python
# Add to coverage calculation response
@router.post("/coverage/calculate")
async def calculate_coverage(...):
result = coverage_service.calculate(...)
# Calculate boundary
if result.points:
boundary = calculate_coverage_boundary(
result.points,
threshold_dbm=settings.min_signal
)
result.boundary = boundary
return result
```
### Frontend
```tsx
// CoverageBoundary.tsx — use returned boundary coords
// Instead of calculating alpha shape on frontend
const CoverageBoundary = ({ points, boundary }) => {
// If server returned boundary, use it
if (boundary && boundary.length > 0) {
return <Polygon positions={boundary.map(p => [p.lat, p.lon])} />;
}
// Fallback to current convex hull implementation
return <CurrentImplementation points={points} />;
};
```
### Dependencies
Need `shapely` installed:
```
pip install shapely # or pip3 install shapely --break-system-packages
```
Check if already in requirements.txt.
### Testing
- [ ] 5km calculation → boundary follows actual coverage shape
- [ ] 10km calculation → boundary is irregular (terrain-dependent)
- [ ] Toggle boundary on/off works
- [ ] Boundary doesn't crash with < 3 points
---
## Task 5: Results Popup Enhancement (Priority 3 — 15 min)
### Problem
Calculation complete toast/popup doesn't show which models were used.
### Solution
Enhance the toast message after calculation:
```tsx
// Current:
toast.success(`Calculated ${points} points in ${time}s`);
// Enhanced:
const modelCount = result.modelsUsed?.length ?? 0;
const freq = sites[0]?.frequency ?? 0;
const presetName = settings.preset ?? 'custom';
toast.success(
`${points} pts • ${time}s • ${presetName}${freq} MHz • ${modelCount} models`,
{ duration: 5000 }
);
```
### Testing
- [ ] After calculation, toast shows: points, time, preset, frequency, model count
---
## Task 6: Native Windows Backend (Priority 3 — Research/Plan)
### Problem
Current setup REQUIRES WSL2. Users without WSL2 can't use RFCP at all.
### Current Flow
```
RFCP.exe (Electron)
→ detects WSL2
→ launches: wsl python3 -m uvicorn ...
→ backend runs in WSL2 Linux
```
### Target Flow
```
RFCP.exe (Electron)
→ Option A: embedded Python (Windows native)
→ Option B: detect system Python (Windows)
→ Option C: keep WSL2 but with fallback
```
### Research Tasks (don't implement yet, just investigate)
1. **Check how Electron currently launches backend:**
```bash
# Look at desktop/ directory
cat desktop/src/main.ts # or main.js
# Find where it spawns python/uvicorn
```
2. **Check if Windows Python works for backend:**
```powershell
# In Windows PowerShell:
cd D:\root\rfcp\backend
python -m uvicorn app.main:app --host 0.0.0.0 --port 8090
# Does it start? What errors?
```
3. **Evaluate embedded Python options:**
- python-embedded (official, ~30 MB)
- PyInstaller (bundle backend as .exe)
- cx_Freeze
- Nuitka (compile Python to C)
4. **Document findings** — create a brief report:
```
RFCP-Native-Backend-Research.md
- Current architecture (WSL2 dependency)
- Windows Python compatibility test results
- Recommended approach
- Migration steps
- Timeline estimate
```
### Goal
User downloads RFCP.exe → installs → clicks icon → everything works.
No WSL2. No manual pip install. GPU auto-detected.
---
## Implementation Order
### Priority 1 (30 min total)
1. **Task 1:** Fix GPU dropdown — make it clickable again
2. **Task 2:** GPU diagnostics + install instructions in UI
3. **Task 3:** Terrain Profile click propagation fix
### Priority 2 (1 hour)
4. **Task 4:** Coverage boundary real contour (shapely)
5. **Task 5:** Results popup enhancement
### Priority 3 (Research only)
6. **Task 6:** Investigate native Windows backend — report only, no implementation
---
## Build & Deploy
```bash
# After implementation:
cd /mnt/d/root/rfcp/frontend
npx tsc --noEmit # TypeScript check
npm run build # Production build
# Rebuild Electron:
cd /mnt/d/root/rfcp/installer
bash build-win.sh
# Test:
# Install new .exe and verify GPU indicator works
```
---
## Success Criteria
- [ ] GPU dropdown opens when clicking badge
- [ ] Dropdown shows device list or install instructions
- [ ] After `pip3 install cupy-cuda12x --break-system-packages` in WSL + restart → GPU visible
- [ ] Terrain Profile click doesn't add ruler points
- [ ] Coverage boundary follows actual signal contour
- [ ] Results toast shows model count and frequency
- [ ] Native Windows backend research document created

View File

@@ -0,0 +1,556 @@
# RFCP — Iteration 3.6.0: Production GPU Build
## Overview
Enable GPU acceleration in the production PyInstaller build. Currently production
runs CPU-only (NumPy) because CuPy is not included in rfcp-server.exe.
**Goal:** User with NVIDIA GPU installs RFCP → GPU detected automatically →
coverage calculations use CUDA acceleration. No manual pip install required.
**Context from diagnostics screenshot:**
```json
{
"python_executable": "C:\\Users\\Administrator\\AppData\\Local\\Programs\\RFCP\\resources\\backend\\rfcp-server.exe",
"platform": "Windows-10-10.0.26288-SP0",
"is_wsl": false,
"numpy": { "version": "1.26.4" },
"cuda": {
"error": "CuPy not installed",
"install_hint": "pip install cupy-cuda12x"
}
}
```
**Architecture:** Production uses PyInstaller-bundled rfcp-server.exe (self-contained).
CuPy not included → GPU not available for end users.
---
## Strategy: Two-Tier Build
Instead of one massive binary, produce two builds:
```
RFCP-Setup-{version}.exe (~150 MB) — CPU-only, works everywhere
RFCP-Setup-{version}-GPU.exe (~700 MB) — includes CuPy + CUDA runtime
```
**Why not dynamic loading?**
PyInstaller bundles everything at build time. CuPy can't be pip-installed
into a frozen exe at runtime. Options are:
1. **Bundle CuPy in PyInstaller** ← cleanest, what we'll do
2. Side-load CuPy DLLs (fragile, version-sensitive)
3. Hybrid: unfrozen Python + CuPy installed separately (defeats purpose of exe)
---
## Task 1: PyInstaller Spec with CuPy (Priority 1 — 30 min)
### File: `installer/rfcp-server-gpu.spec`
Create a separate .spec file that includes CuPy:
```python
# rfcp-server-gpu.spec — GPU-enabled build
import os
import sys
from PyInstaller.utils.hooks import collect_all, collect_dynamic_libs
backend_path = os.path.abspath(os.path.join(os.path.dirname(SPEC), '..', 'backend'))
# Collect CuPy and its CUDA dependencies
cupy_datas, cupy_binaries, cupy_hiddenimports = collect_all('cupy')
# Also collect cupy_backends
cupyb_datas, cupyb_binaries, cupyb_hiddenimports = collect_all('cupy_backends')
# CUDA runtime libraries that CuPy needs
cuda_binaries = collect_dynamic_libs('cupy')
a = Analysis(
[os.path.join(backend_path, 'run_server.py')],
pathex=[backend_path],
binaries=cupy_binaries + cupyb_binaries + cuda_binaries,
datas=[
(os.path.join(backend_path, 'data', 'terrain'), 'data/terrain'),
] + cupy_datas + cupyb_datas,
hiddenimports=[
# Existing imports from rfcp-server.spec
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'motor',
'pymongo',
'numpy',
'scipy',
'shapely',
'shapely.geometry',
'shapely.ops',
# CuPy-specific
'cupy',
'cupy.cuda',
'cupy.cuda.runtime',
'cupy.cuda.driver',
'cupy.cuda.memory',
'cupy.cuda.stream',
'cupy._core',
'cupy._core.core',
'cupy._core._routines_math',
'cupy.fft',
'cupy.linalg',
'fastrlock',
] + cupy_hiddenimports + cupyb_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='rfcp-server',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # Don't compress CUDA libs — they need fast loading
console=True,
icon=os.path.join(os.path.dirname(SPEC), 'rfcp.ico'),
)
```
### Key Points:
- `collect_all('cupy')` grabs all CuPy submodules + CUDA DLLs
- `fastrlock` is a CuPy dependency (must be in hiddenimports)
- `upx=False` — don't compress CUDA binaries (breaks them)
- One-file mode (`a.binaries + a.datas` in EXE) for single exe
---
## Task 2: Build Script for GPU Variant (Priority 1 — 15 min)
### File: `installer/build-gpu.bat` (Windows)
```batch
@echo off
echo ========================================
echo RFCP GPU Build — rfcp-server-gpu.exe
echo ========================================
REM Ensure CuPy is installed in build environment
echo Checking CuPy installation...
python -c "import cupy; print(f'CuPy {cupy.__version__} with CUDA {cupy.cuda.runtime.runtimeGetVersion()}')"
if errorlevel 1 (
echo ERROR: CuPy not installed. Run: pip install cupy-cuda12x
exit /b 1
)
REM Build with GPU spec
echo Building rfcp-server with GPU support...
cd /d %~dp0\..\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --clean --noconfirm
echo.
echo Build complete! Output: dist\rfcp-server.exe
echo Size:
dir dist\rfcp-server.exe
REM Optional: copy to Electron resources
if exist "..\desktop\resources" (
copy /y dist\rfcp-server.exe ..\desktop\resources\rfcp-server.exe
echo Copied to desktop\resources\
)
pause
```
### File: `installer/build-gpu.sh` (WSL/Linux)
```bash
#!/bin/bash
set -e
echo "========================================"
echo " RFCP GPU Build — rfcp-server (GPU)"
echo "========================================"
# Check CuPy
python3 -c "import cupy; print(f'CuPy {cupy.__version__}')" 2>/dev/null || {
echo "ERROR: CuPy not installed. Run: pip install cupy-cuda12x"
exit 1
}
cd "$(dirname "$0")/../backend"
pyinstaller ../installer/rfcp-server-gpu.spec --clean --noconfirm
echo ""
echo "Build complete!"
ls -lh dist/rfcp-server*
```
---
## Task 3: GPU Backend — Graceful CuPy Detection (Priority 1 — 15 min)
### File: `backend/app/services/gpu_backend.py`
The existing gpu_backend.py should already handle CuPy absence gracefully.
Verify and fix if needed:
```python
# gpu_backend.py — must work in BOTH CPU and GPU builds
import numpy as np
# Try importing CuPy — this is the key detection
_cupy_available = False
_gpu_device_name = None
_gpu_memory_mb = 0
try:
import cupy as cp
# Verify we can actually use it (not just import)
device = cp.cuda.Device(0)
_gpu_device_name = device.attributes.get('name', f'CUDA Device {device.id}')
# Try to get name via runtime
try:
props = cp.cuda.runtime.getDeviceProperties(0)
_gpu_device_name = props.get('name', _gpu_device_name)
if isinstance(_gpu_device_name, bytes):
_gpu_device_name = _gpu_device_name.decode('utf-8').strip('\x00')
except Exception:
pass
_gpu_memory_mb = device.mem_info[1] // (1024 * 1024)
_cupy_available = True
except ImportError:
cp = None # CuPy not installed (CPU build)
except Exception as e:
cp = None # CuPy installed but CUDA not available
print(f"[GPU] CuPy found but CUDA unavailable: {e}")
def is_gpu_available() -> bool:
return _cupy_available
def get_gpu_info() -> dict:
if _cupy_available:
return {
"available": True,
"backend": "CuPy (CUDA)",
"device": _gpu_device_name,
"memory_mb": _gpu_memory_mb,
}
return {
"available": False,
"backend": "NumPy (CPU)",
"device": "CPU",
"memory_mb": 0,
}
def get_array_module():
"""Return cupy if available, otherwise numpy."""
if _cupy_available:
return cp
return np
```
### Usage in coverage_service.py:
```python
from app.services.gpu_backend import get_array_module, is_gpu_available
xp = get_array_module() # cupy or numpy — same API
# All calculations use xp instead of np:
distances = xp.sqrt(dx**2 + dy**2)
path_loss = 20 * xp.log10(distances) + 20 * xp.log10(freq_mhz) - 27.55
# If using cupy, results need to come back to CPU for JSON serialization:
if is_gpu_available():
results = xp.asnumpy(path_loss)
else:
results = path_loss
```
---
## Task 4: GPU Status in Frontend Header (Priority 2 — 10 min)
### Update GPUIndicator.tsx
When GPU is detected, the badge should clearly show it:
```
CPU build: [⚙ CPU] (gray badge)
GPU detected: [⚡ RTX 4060] (green badge)
```
The existing GPUIndicator already does this. Just verify:
1. Badge color changes from gray → green when GPU available
2. Dropdown shows "Active: GPU (CUDA)" not just "CPU (NumPy)"
3. No install hints shown when CuPy IS available
---
## Task 5: Build Environment Setup (Priority 1 — Manual by Олег)
### Prerequisites for GPU build:
```powershell
# 1. Install CuPy in Windows Python (NOT WSL)
pip install cupy-cuda12x
# 2. Verify CuPy works
python -c "import cupy; print(cupy.cuda.runtime.runtimeGetVersion())"
# Should print: 12000 or similar
# 3. Install PyInstaller if not present
pip install pyinstaller
# 4. Verify fastrlock (CuPy dependency)
pip install fastrlock
```
### Build commands:
```powershell
# CPU-only build (existing)
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server.spec --clean --noconfirm
# GPU build (new)
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --clean --noconfirm
```
### Expected output sizes:
```
rfcp-server.exe (CPU): ~80 MB
rfcp-server.exe (GPU): ~600-800 MB (CuPy bundles CUDA runtime libs)
```
---
## Task 6: Electron — Detect Build Variant (Priority 2 — 10 min)
### File: `desktop/main.js` or `desktop/src/main.ts`
Add version detection so UI knows which build it's running:
```javascript
// After backend starts, check GPU status
async function checkBackendCapabilities() {
try {
const response = await fetch('http://127.0.0.1:8090/api/gpu/status');
const data = await response.json();
// Send to renderer
mainWindow.webContents.send('gpu-status', data);
if (data.available) {
console.log(`[RFCP] GPU: ${data.device} (${data.memory_mb} MB)`);
} else {
console.log('[RFCP] Running in CPU mode');
}
} catch (e) {
console.log('[RFCP] Backend not ready for GPU check');
}
}
```
---
## Task 7: About / Version Info (Priority 3 — 5 min)
### Add build info to `/api/health` response:
```python
@app.get("/api/health")
async def health():
gpu_info = get_gpu_info()
return {
"status": "ok",
"version": "3.6.0",
"build": "gpu" if gpu_info["available"] else "cpu",
"gpu": gpu_info,
"python": sys.version,
"platform": platform.platform(),
}
```
---
## Build & Test Procedure
### Step 1: Setup Build Environment
```powershell
# Windows PowerShell (NOT WSL)
cd D:\root\rfcp
# Verify Python environment
python --version # Should be 3.11.x
pip list | findstr cupy # Should show cupy-cuda12x
# If CuPy not installed:
pip install cupy-cuda12x fastrlock
```
### Step 2: Build GPU Variant
```powershell
cd D:\root\rfcp\backend
pyinstaller ..\installer\rfcp-server-gpu.spec --clean --noconfirm
```
### Step 3: Test Standalone
```powershell
# Run the built exe directly
.\dist\rfcp-server.exe
# In another terminal:
curl http://localhost:8090/api/health
curl http://localhost:8090/api/gpu/status
curl http://localhost:8090/api/gpu/diagnostics
```
### Step 4: Verify GPU Detection
Expected `/api/gpu/status` response:
```json
{
"available": true,
"backend": "CuPy (CUDA)",
"device": "NVIDIA GeForce RTX 4060 Laptop GPU",
"memory_mb": 8188
}
```
### Step 5: Run Coverage Calculation
- Place a site on map
- Calculate coverage (10km, 200m resolution)
- Check logs for: `[GPU] Using CUDA: RTX 4060 (8188 MB)`
- Compare performance: should be 5-10x faster than CPU
### Step 6: Full Electron Build
```powershell
# Copy GPU server to Electron resources
copy backend\dist\rfcp-server.exe desktop\resources\
# Build Electron installer
cd installer
.\build-win.sh # or equivalent Windows script
```
---
## Risk Assessment
### Size Concern
CuPy bundles CUDA runtime (~500MB). Total GPU installer ~700-800MB.
**Mitigation:** This is acceptable for a professional RF planning tool.
AutoCAD is 7GB. QGIS is 1.5GB. Atoll is 3GB+.
### CUDA Version Compatibility
CuPy-cuda12x requires CUDA 12.x compatible driver.
RTX 4060 with Driver 581.42 → CUDA 13.0 → backward compatible ✅
**Mitigation:** gpu_backend.py already falls back to NumPy gracefully.
### PyInstaller + CuPy Issues
Known issues:
- CuPy uses many .so/.dll files that PyInstaller might miss
- `collect_all('cupy')` should catch them, but test thoroughly
- If missing DLLs → add them manually to `binaries` list
**Mitigation:** Test the standalone exe on a clean machine (no Python installed).
### Antivirus False Positives
Larger exe = more AV suspicion. PyInstaller exes already trigger some AV.
**Mitigation:** Code-sign the exe (future task), submit to AV vendors for whitelisting.
---
## Success Criteria
- [ ] `rfcp-server-gpu.spec` created and builds successfully
- [ ] Built exe detects RTX 4060 on startup
- [ ] `/api/gpu/status` returns `"available": true`
- [ ] Coverage calculation uses CuPy (check logs)
- [ ] GPU badge shows "⚡ RTX 4060" (green) in header
- [ ] Fallback to NumPy works if CUDA unavailable
- [ ] CPU-only spec (`rfcp-server.spec`) still builds and works
- [ ] Build time < 10 minutes
- [ ] GPU exe size < 1 GB
---
## Commit Message
```
feat(build): add GPU-enabled PyInstaller build with CuPy + CUDA
- New rfcp-server-gpu.spec with CuPy/CUDA collection
- Build scripts: build-gpu.bat, build-gpu.sh
- Graceful GPU detection in gpu_backend.py
- Two-tier build: CPU (~80MB) and GPU (~700MB) variants
- Auto-detection: RTX 4060 → CuPy acceleration
- Fallback: no CUDA → NumPy (CPU mode)
Iteration 3.6.0 — Production GPU Build
```
---
## Files Summary
### New Files:
| File | Purpose |
|------|---------|
| `installer/rfcp-server-gpu.spec` | PyInstaller config with CuPy |
| `installer/build-gpu.bat` | Windows GPU build script |
| `installer/build-gpu.sh` | Linux/WSL GPU build script |
### Modified Files:
| File | Changes |
|------|---------|
| `backend/app/services/gpu_backend.py` | Verify graceful detection |
| `backend/app/main.py` | Health endpoint with build info |
| `desktop/main.js` or `main.ts` | GPU status check after backend start |
| `frontend/src/components/ui/GPUIndicator.tsx` | Verify badge shows GPU |
### No Changes Needed:
| File | Reason |
|------|--------|
| `installer/rfcp-server.spec` | CPU build stays as-is |
| `backend/app/services/coverage_service.py` | Already uses get_array_module() |
| `installer/build-win.sh` | Existing CPU build unchanged |
---
## Timeline
| Phase | Task | Time |
|-------|------|------|
| **P1** | Create rfcp-server-gpu.spec | 30 min |
| **P1** | Build scripts | 15 min |
| **P1** | Verify gpu_backend.py | 15 min |
| **P2** | Frontend badge verification | 10 min |
| **P2** | Electron GPU status | 10 min |
| **P3** | Health endpoint update | 5 min |
| **Test** | Build + test standalone | 20 min |
| **Test** | Full Electron build | 15 min |
| | **Total** | **~2 hours** |
**Claude Code estimated time: 10-15 min** (spec + scripts + backend changes)
**Manual testing by Олег: 30-45 min** (building + verifying)

View File

@@ -0,0 +1,220 @@
# RFCP Project Roadmap — Updated February 4, 2026
**Project:** RFCP (RF Coverage Planning) for UMTC
**Developer:** Олег + Claude
**Started:** January 30, 2025
**Current Version:** 3.8.0 (GPU Acceleration Complete)
---
## ✅ Completed Milestones
### Phase 1: Frontend (January 2025)
- ✅ React + TypeScript + Vite + Leaflet
- ✅ Multi-site RF coverage planning
- ✅ Multi-sector sites (Alpha/Beta/Gamma)
- ✅ Geographic-scale canvas heatmap
- ✅ Keyboard shortcuts + delete confirmation
- ✅ NumberInput components with sliders
- ✅ TypeScript strict mode, ESLint clean
- ✅ Production build: 536KB / 163KB gzipped
### Phase 2: Backend Architecture (February 1, 2025)
- ✅ Python FastAPI + NumPy + ProcessPoolExecutor
- ✅ 8 propagation models (FreeSpace, Okumura-Hata, COST-231, ITU-R P.1546, etc.)
- ✅ Modular geometry engine (haversine, intersection, reflection, diffraction, LOS)
- ✅ SharedMemoryManager for terrain data (zero-copy, 25 MB)
- ✅ Building filtering (351k → 27k bbox → 15k cap)
- ✅ Overpass API with retry + mirror failover
- ✅ WebSocket progress streaming
### Phase 3: Performance (February 2-3, 2025)
- ✅ LOD (Level of Detail) optimization
- ✅ Spatial indexing for buildings (R-tree)
- ✅ Dominant path simplification for distant points
- ✅ OOM fix + memory management
- ✅ CloudRF-style color gradient
- ✅ Results popup + session history
- ✅ Terrain profile viewer
### Phase 4: GPU Acceleration (February 3-4, 2025) ⭐
- ✅ CuPy + CUDA backend (RTX 4060)
- ✅ CUDA Toolkit 13.1 + cupy-cuda13x setup
- ✅ Phase 2.5: Vectorized distances + path_loss (0.006s)
- ✅ Phase 2.6: Vectorized terrain LOS + diffraction (0.04s)
- ✅ Phase 2.7: Vectorized antenna pattern loss
- ✅ Vegetation bbox pre-filter (100x+ speedup)
- ✅ Worker process isolation (no CUDA in workers)
- ✅ PyInstaller ONEDIR GPU build (1.2 GB installer)
-**Full preset: 195s → 11.2s (17.4x speedup)**
### Supporting Work
- ✅ RF Radio Theory wiki article (comprehensive)
- ✅ Propagation model research (CloudRF, SPLAT!, Signal Server)
- ✅ RFCP Method collaboration framework documented
---
## 📊 Current Performance
| Preset | Points | Resolution | Time (cached) | Time (cold) |
|--------|--------|-----------|---------------|-------------|
| Standard | 1,975 | 200m | **2.3s** | ~12s |
| Full | 6,640 | 50m | **11.2s** | ~20s |
| 50km radius | 4,966 | adaptive | ~410s | ~420s |
**Hardware:** Windows 11, RTX 4060 Laptop GPU, 6-core CPU
---
## 🔜 Next: Phase 5 — Data & Accuracy
### 5.1 SRTM Terrain Integration
**Priority:** HIGH
**Status:** Not started
Current terrain: Single HGT tile download per calculation
Target: Pre-cached SRTM/ASTER DEM tiles with proper interpolation
- [ ] SRTM tile manager (auto-download, cache)
- [ ] Bilinear interpolation for elevation sampling
- [ ] Multi-tile coverage for large radius
- [ ] Terrain profile accuracy validation
- [ ] Compare with current terrain data quality
### 5.2 Project Persistence
**Priority:** MEDIUM
- [ ] Save/load projects (JSON or SQLite)
- [ ] Site configurations persistence
- [ ] Coverage results caching
- [ ] Session history persistence across restarts
- [ ] Export coverage report (PDF/PNG)
### 5.3 Accuracy Validation
**Priority:** MEDIUM
- [ ] Compare with known coverage maps
- [ ] Field measurements with real equipment
- [ ] Calibrate propagation models per environment
- [ ] Antenna pattern library (real equipment specs)
---
## 🔮 Future Phases
### Phase 6: Multi-Station & Dashboard
- [ ] Multi-station view (aggregate coverage)
- [ ] Station discovery via WireGuard mesh
- [ ] Coverage gap analysis
- [ ] Interference modeling between stations
- [ ] Handover zone visualization
### Phase 7: Hardware Integration
- [ ] LimeSDR Mini 2.0 testing
- [ ] Real RF attach validation
- [ ] sysmoISIM-SJA2 SIM integration
- [ ] ZTE B8200 base station testing
- [ ] INFOZAHYST Plastun SDR (if accessible)
### Phase 8: Advanced Features
- [ ] 3D visualization mode
- [ ] Link budget analysis view
- [ ] Frequency planning tool
- [ ] Indoor coverage modeling
- [ ] Time-series analysis (seasonal vegetation)
- [ ] Offline mode (embedded terrain DB)
### Phase 9: Distribution
- [ ] Auto-updater (electron-updater)
- [ ] Live USB distribution for field deployment
- [ ] Standalone offline package
- [ ] User documentation / help system
---
## 🏛️ Architecture Overview
```
RFCP Application (Electron)
├── Frontend (React + TypeScript + Vite)
│ ├── Leaflet map with custom canvas heatmap
│ ├── Zustand state management
│ └── WebSocket for progress streaming
├── Backend (Python FastAPI)
│ ├── Coverage Engine
│ │ ├── Grid generator (adaptive zones)
│ │ ├── GPU pipeline (CuPy/CUDA) — main process
│ │ │ ├── Phase 2.5: distances + path_loss
│ │ │ ├── Phase 2.6: terrain LOS + diffraction
│ │ │ └── Phase 2.7: antenna pattern
│ │ └── CPU workers (ProcessPool) — 3-6 workers
│ │ ├── Building obstruction (spatial index)
│ │ ├── Reflections (ray-building intersection)
│ │ └── Vegetation loss (bbox pre-filter)
│ │
│ ├── Propagation Models (8 models)
│ │ ├── Free-Space Path Loss
│ │ ├── Okumura-Hata (150-1500 MHz)
│ │ ├── COST-231-Hata (1500-2000 MHz)
│ │ ├── ITU-R P.1546
│ │ └── ... 4 more
│ │
│ ├── OSM Services
│ │ ├── Buildings (Overpass API + cache)
│ │ ├── Vegetation (bbox pre-filter)
│ │ ├── Water bodies
│ │ └── Streets
│ │
│ └── Terrain Service
│ ├── HGT tile download + cache
│ ├── Elevation sampling
│ └── Line-of-sight checking
└── Desktop (Electron)
├── Backend process management
└── NSIS installer (1.2 GB with CUDA)
```
---
## 📈 Development Timeline
```
Jan 30, 2025 Phase 1: Frontend complete (10 iterations)
Feb 01, 2025 Phase 2: Backend architecture (48 files, 82 tests)
Feb 02, 2025 Phase 3: LOD + performance optimization
Feb 03, 2025 Phase 3.5-3.6: GPU setup + CUDA build
Feb 04, 2025 Phase 3.7-3.8: GPU vectorization complete ⭐
─────────────────────────────────────────
Full preset: 195s → 11.2s (17.4x speedup)
Standard: 38s → 2.3s (16.5x speedup)
```
**Total development time:** ~5 days intensive
**Total iterations:** 3.8.0 (20+ sub-iterations)
**Architecture:** Battle-tested, production-ready
---
## 🧰 Tech Stack
| Component | Technology | Version |
|-----------|-----------|---------|
| Frontend | React + TypeScript | 18 |
| Build | Vite | 5.x |
| Map | Leaflet | 1.9 |
| State | Zustand | 4.x |
| Backend | Python FastAPI | 3.12 |
| GPU | CuPy + CUDA | 13.x |
| Parallel | ProcessPoolExecutor | stdlib |
| Terrain | NumPy (HGT tiles) | 1.26 |
| Desktop | Electron | 28.x |
| Installer | NSIS (via electron-builder) | - |
| Build (BE) | PyInstaller | 6.x |
---
*"11.2 seconds. Full preset. 6,640 points. GPU acceleration complete."*
*— February 4, 2026*

View File

@@ -0,0 +1,149 @@
# RFCP Session Summary — February 4, 2026
## GPU Acceleration Complete: 195s → 11.2s (17.4x Speedup)
---
## 🎯 Session Goal
Complete GPU acceleration pipeline and optimize Full preset performance.
## 📊 Results
### Performance Achievement
| Metric | Before (3.7.0) | After (3.8.0) | Improvement |
|--------|----------------|---------------|-------------|
| **Full preset** (6640 pts, 50m) | 195s | **11.2s** | **17.4x** |
| **Standard preset** (1975 pts, 200m) | 7.2s | **2.3s** (cached) | **3.1x** |
| Phase 2.5 (distances+path_loss) | 0.33s | **0.006s** | 55x |
| Phase 2.6 (terrain LOS) | 7.29s | **0.04s** | 182x |
| Per-point (workers) | 1.1ms | **0.1ms** | 11x |
### GPU Pipeline (Final Architecture)
```
Phase 1: OSM data fetch (Overpass API) ~6-10s (network)
Phase 2: Terrain tile download + cache ~4s first / 0s cached
Phase 2.5: GPU — distances + base path_loss 0.006s ⚡
Phase 2.6: GPU — terrain LOS + diffraction loss 0.04s ⚡
Phase 2.7: GPU — antenna pattern loss ~0s ⚡
Phase 3: CPU workers — buildings + vegetation ~2s
─────────────────────────────────────────────────
TOTAL (cached): ~2.3s (Standard)
TOTAL (cached): ~11.2s (Full)
```
---
## 🔧 Changes Made (Iterations 3.7.0 → 3.8.0)
### Iteration 3.7.0 — GPU Precompute Foundation
- Added `gpu_manager` import to `coverage_service.py`
- Grid arrays created on GPU (CuPy)
- GPU precompute for distances + path_loss (vectorized)
- Fixed critical bug: CuPy worker process crashes (CUDA context sharing)
- Solution: GPU only in main process, workers use precomputed CPU values
- Fixed frontend duplicate calculation guard
### Iteration 3.8.0 — Full Vectorization
- **Phase 2.6**: `batch_terrain_los()` in `gpu_service.py`
- Vectorized terrain profile sampling for ALL points simultaneously
- Earth curvature correction vectorized
- Fresnel clearance + diffraction loss vectorized
- **Phase 2.7**: `batch_antenna_pattern()` in `gpu_service.py`
- Workers receive precomputed `has_los`, `terrain_loss`, `antenna_loss`
- Workers only compute buildings + reflections + vegetation
### Critical Fix: `_batch_elevation_lookup` Vectorization
- **Before**: Python `for` loop over 59,250 coordinates (7.29s)
- **After**: Vectorized NumPy tile indexing, loop only over tiles (0.04s)
- **Impact**: 182x speedup on Phase 2.6 alone
### Critical Fix: Vegetation Bbox Pre-filter
- **Before**: Each sample point checked ALL 683 vegetation polygons
- **After**: Bounding box pre-filter skips 95%+ of polygons
- **Impact**: Full preset 156s → 11.2s
---
## 📁 Files Modified
### Backend
- `app/services/coverage_service.py` — precomputed values passthrough
- `app/services/parallel_coverage_service.py` — 5 worker functions updated
- `app/services/gpu_service.py` — batch_terrain_los, batch_antenna_pattern, batch_final_rsrp
- `app/services/vegetation_service.py` — bbox pre-filter on _point_in_vegetation
### Build
- PyInstaller ONEDIR build: 1.6 GB dist → 1.2 GB NSIS installer
- CUDA DLLs bundled (cublas, cusparse, curand, etc.)
- Runtime hook for DLL directory setup
---
## 🏗️ Architecture (Final State)
```
Main Process (asyncio event loop)
├── Phase 2.5: GPU precompute
│ └── CuPy arrays: distances, path_loss (vectorized)
├── Phase 2.6: GPU terrain LOS
│ └── Batch elevation lookup (vectorized NumPy)
│ └── Earth curvature + Fresnel (CuPy)
│ └── Diffraction loss (CuPy)
├── Phase 2.7: GPU antenna pattern
│ └── Bearing + pattern loss (CuPy)
└── Phase 3: CPU ProcessPool (3 workers)
└── Receive precomputed dict per point
└── Skip terrain/antenna (already computed)
└── Only: buildings + reflections + vegetation
└── Pure NumPy + CPU
```
**Key Rule**: GPU (CuPy) code ONLY in main process. Workers never import gpu_manager.
---
## 🎮 Side Activity: Dwarf Fortress Gamelog Analysis
Analyzed 102,669-line gamelog from fort "Lashderush (Prophethandle)":
- 8-9 years, 23 migrant waves, 1,943 masterpieces
- 51,599 combat actions, only 4 deaths (weredeer outbreak)
- Top crafter: Momuz Nëkorlibash (201 masterpieces)
- Sole survivor transforms between dwarf/weredeer
---
## 🔮 Next Steps
### Immediate
- [x] ~~GPU acceleration~~ ✅ COMPLETE
- [ ] SRTM terrain data integration (higher accuracy than current tiles)
- [ ] Session history persistence across app restarts
### Short Term
- [ ] Multi-station dashboard
- [ ] Project export/import (JSON)
- [ ] Link budget analysis view
### Medium Term
- [ ] LimeSDR hardware integration testing
- [ ] Real RF validation against field measurements
- [ ] 3D visualization mode
---
## 💡 Key Learnings
1. **Python for-loops are the enemy**`_batch_elevation_lookup` went from 7.3s to 0.04s by replacing enumerate(zip()) with NumPy indexing
2. **Spatial pre-filtering is massive** — vegetation bbox check eliminated 95%+ of polygon tests
3. **GPU context can't be shared across processes** — spawn mode creates new CUDA contexts that OOM
4. **Vectorize in main, distribute to workers** — best pattern for GPU + multiprocessing
5. **Profile before optimizing** — Phase 2.6 bottleneck was invisible until measured
---
*Session duration: ~4 hours*
*Lines of code changed: ~300*
*Performance gain: 17.4x*
*Feeling: 🚀*