@mytec: iter3.10 start, baseline rc ready
This commit is contained in:
130
docs/devlog/gpu_supp/RFCP-3.6.0-GPU-Build-Task.md
Normal file
130
docs/devlog/gpu_supp/RFCP-3.6.0-GPU-Build-Task.md
Normal 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)
|
||||
133
docs/devlog/gpu_supp/RFCP-3.7.0-GPU-Coverage-Task.md
Normal file
133
docs/devlog/gpu_supp/RFCP-3.7.0-GPU-Coverage-Task.md
Normal 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
|
||||
181
docs/devlog/gpu_supp/RFCP-3.8.0-Vectorize-Coverage-Task.md
Normal file
181
docs/devlog/gpu_supp/RFCP-3.8.0-Vectorize-Coverage-Task.md
Normal 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
|
||||
436
docs/devlog/gpu_supp/RFCP-3.9.0-SRTM-Terrain-Integration.md
Normal file
436
docs/devlog/gpu_supp/RFCP-3.9.0-SRTM-Terrain-Integration.md
Normal 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°×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
|
||||
656
docs/devlog/gpu_supp/RFCP-Dependencies-Installer.md
Normal file
656
docs/devlog/gpu_supp/RFCP-Dependencies-Installer.md
Normal 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}
|
||||
```
|
||||
557
docs/devlog/gpu_supp/RFCP-Iteration-3.5.1-Bugfixes-Polish.md
Normal file
557
docs/devlog/gpu_supp/RFCP-Iteration-3.5.1-Bugfixes-Polish.md
Normal 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"* ✨
|
||||
504
docs/devlog/gpu_supp/RFCP-Iteration-3.5.2-Native-GPU-Polish.md
Normal file
504
docs/devlog/gpu_supp/RFCP-Iteration-3.5.2-Native-GPU-Polish.md
Normal 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
|
||||
@@ -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)
|
||||
220
docs/devlog/gpu_supp/RFCP-Roadmap-Updated-2026-02-04.md
Normal file
220
docs/devlog/gpu_supp/RFCP-Roadmap-Updated-2026-02-04.md
Normal 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*
|
||||
@@ -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: 🚀*
|
||||
Reference in New Issue
Block a user