Compare commits
2 Commits
f5429e40fd
...
867ee3d0f4
| Author | SHA1 | Date | |
|---|---|---|---|
| 867ee3d0f4 | |||
| 7f0b4d2269 |
439
RFCP-Iteration-3.4.0-Large-Radius-Support.md
Normal file
439
RFCP-Iteration-3.4.0-Large-Radius-Support.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# RFCP Iteration 3.4.0 — Large Radius Support (20-50km)
|
||||
|
||||
## Goal
|
||||
|
||||
Enable 50km radius calculations without OOM by implementing memory-efficient processing patterns.
|
||||
|
||||
**Current limitation:** > 10-20km radius causes OOM (5+ GB RAM usage)
|
||||
**Target:** 50km radius with < 4GB RAM peak
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Memory-Mapped Terrain
|
||||
|
||||
### 1.1 Terrain mmap Loading
|
||||
|
||||
Change terrain_service to use memory-mapped files instead of loading full arrays into RAM.
|
||||
|
||||
**File:** `backend/app/services/terrain_service.py`
|
||||
|
||||
```python
|
||||
# Before (loads ~25 MB per tile into RAM):
|
||||
terrain = np.fromfile(f, dtype='>i2').reshape((rows, cols))
|
||||
|
||||
# After (near-zero RAM, OS pages from disk):
|
||||
terrain = np.memmap(f, dtype='>i2', mode='r', shape=(rows, cols))
|
||||
```
|
||||
|
||||
**Expected impact:** -200-400 MB RAM per tile
|
||||
|
||||
### 1.2 Terrain Disk Cache
|
||||
|
||||
- Save downloaded .hgt files to persistent disk cache
|
||||
- Don't keep raw arrays in memory after initial processing
|
||||
- Implement LRU eviction if cache exceeds 2GB
|
||||
- Location: `~/.rfcp/terrain_cache/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Tile-Based Processing
|
||||
|
||||
### 2.1 Split Large Calculations
|
||||
|
||||
If radius > 10km, split calculation area into 5km sub-tiles.
|
||||
|
||||
**File:** `backend/app/services/coverage_service.py` (or new `tile_processor.py`)
|
||||
|
||||
```python
|
||||
def calculate_coverage_tiled(site, radius_m, resolution_m, settings):
|
||||
"""Tile-based calculation for large radius."""
|
||||
|
||||
# Small radius — use existing single-pass
|
||||
if radius_m <= 10000:
|
||||
return calculate_coverage_single(site, radius_m, resolution_m, settings)
|
||||
|
||||
# Large radius — split into tiles
|
||||
TILE_SIZE = 5000 # 5km tiles
|
||||
tiles = generate_tile_grid(site.lat, site.lon, radius_m, TILE_SIZE)
|
||||
|
||||
all_results = []
|
||||
|
||||
for i, tile in enumerate(tiles):
|
||||
log(f"Processing tile {i+1}/{len(tiles)}: {tile.bbox}")
|
||||
|
||||
# Load data for this tile only
|
||||
tile_terrain = load_terrain_for_bbox(tile.bbox)
|
||||
tile_buildings = load_buildings_for_bbox(tile.bbox)
|
||||
|
||||
# Calculate coverage for tile
|
||||
tile_points = generate_grid_for_tile(tile, resolution_m)
|
||||
tile_results = calculate_points(tile_points, site, settings,
|
||||
tile_terrain, tile_buildings)
|
||||
|
||||
all_results.extend(tile_results)
|
||||
|
||||
# Free memory
|
||||
del tile_terrain, tile_buildings
|
||||
gc.collect()
|
||||
|
||||
# Report progress
|
||||
progress = (i + 1) / len(tiles) * 100
|
||||
yield_progress(progress, f"Tile {i+1}/{len(tiles)}")
|
||||
|
||||
return merge_and_dedupe_results(all_results)
|
||||
|
||||
|
||||
def generate_tile_grid(center_lat, center_lon, radius_m, tile_size_m):
|
||||
"""Generate grid of tiles covering the calculation area."""
|
||||
tiles = []
|
||||
|
||||
# Calculate bbox of full area
|
||||
lat_delta = radius_m / 111000
|
||||
lon_delta = radius_m / (111000 * cos(radians(center_lat)))
|
||||
|
||||
# Generate tile grid
|
||||
n_tiles = ceil(radius_m * 2 / tile_size_m)
|
||||
|
||||
for i in range(n_tiles):
|
||||
for j in range(n_tiles):
|
||||
tile_bbox = calculate_tile_bbox(center_lat, center_lon,
|
||||
i, j, n_tiles, tile_size_m)
|
||||
|
||||
# Only include tiles that intersect with coverage circle
|
||||
if tile_intersects_circle(tile_bbox, center_lat, center_lon, radius_m):
|
||||
tiles.append(Tile(bbox=tile_bbox, index=(i, j)))
|
||||
|
||||
return tiles
|
||||
```
|
||||
|
||||
### 2.2 Progressive Results via WebSocket
|
||||
|
||||
Send results per-tile as they complete, so user sees coverage growing.
|
||||
|
||||
**File:** `backend/app/api/websocket.py`
|
||||
|
||||
```python
|
||||
async def calculate_coverage_ws(websocket, params):
|
||||
for tile_results in calculate_coverage_tiled_generator(params):
|
||||
# Send partial results
|
||||
await websocket.send_json({
|
||||
"type": "partial_results",
|
||||
"points": tile_results.points,
|
||||
"progress": tile_results.progress,
|
||||
"tile": tile_results.tile_index,
|
||||
"status": f"Tile {tile_results.tile_index} complete"
|
||||
})
|
||||
|
||||
# Final message
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"total_points": total_points,
|
||||
"computation_time": elapsed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: SQLite Cache for OSM Data
|
||||
|
||||
### 3.1 Create Local Database
|
||||
|
||||
Replace in-memory OSM cache with SQLite database with spatial indexing.
|
||||
|
||||
**File:** `backend/app/services/cache_db.py` (NEW)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
class OSMCacheDB:
|
||||
def __init__(self, db_path="~/.rfcp/osm_cache.db"):
|
||||
self.conn = sqlite3.connect(db_path)
|
||||
self._init_tables()
|
||||
|
||||
def _init_tables(self):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
osm_id TEXT UNIQUE,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
height REAL DEFAULT 10.0,
|
||||
geometry TEXT, -- GeoJSON
|
||||
cell_key TEXT, -- grid cell for batch loading
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_lat ON buildings(lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_lon ON buildings(lon);
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_cell ON buildings(cell_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vegetation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
osm_id TEXT UNIQUE,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
type TEXT,
|
||||
geometry TEXT,
|
||||
cell_key TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_veg_lat ON vegetation(lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_veg_lon ON vegetation(lon);
|
||||
|
||||
-- Metadata for cache invalidation
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
cell_key TEXT PRIMARY KEY,
|
||||
data_type TEXT,
|
||||
fetched_at TIMESTAMP,
|
||||
item_count INTEGER
|
||||
);
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
def query_buildings_bbox(self, min_lat, max_lat, min_lon, max_lon, limit=20000):
|
||||
"""Query buildings within bounding box."""
|
||||
cursor = self.conn.execute("""
|
||||
SELECT osm_id, lat, lon, height, geometry
|
||||
FROM buildings
|
||||
WHERE lat BETWEEN ? AND ?
|
||||
AND lon BETWEEN ? AND ?
|
||||
LIMIT ?
|
||||
""", (min_lat, max_lat, min_lon, max_lon, limit))
|
||||
|
||||
return [self._row_to_building(row) for row in cursor]
|
||||
|
||||
def insert_buildings(self, buildings, cell_key):
|
||||
"""Bulk insert buildings from OSM fetch."""
|
||||
self.conn.executemany("""
|
||||
INSERT OR IGNORE INTO buildings
|
||||
(osm_id, lat, lon, height, geometry, cell_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", [
|
||||
(b['id'], b['lat'], b['lon'], b.get('height', 10),
|
||||
json.dumps(b.get('geometry')), cell_key)
|
||||
for b in buildings
|
||||
])
|
||||
self.conn.commit()
|
||||
|
||||
def is_cell_cached(self, cell_key, data_type, max_age_hours=24):
|
||||
"""Check if cell data is cached and fresh."""
|
||||
cursor = self.conn.execute("""
|
||||
SELECT fetched_at FROM cache_meta
|
||||
WHERE cell_key = ? AND data_type = ?
|
||||
AND fetched_at > datetime('now', ?)
|
||||
""", (cell_key, data_type, f'-{max_age_hours} hours'))
|
||||
|
||||
return cursor.fetchone() is not None
|
||||
```
|
||||
|
||||
### 3.2 Update OSM Client
|
||||
|
||||
Modify OSM client to use SQLite cache.
|
||||
|
||||
**File:** `backend/app/services/osm_client.py`
|
||||
|
||||
```python
|
||||
class OSMClient:
|
||||
def __init__(self):
|
||||
self.cache_db = OSMCacheDB()
|
||||
|
||||
def get_buildings(self, bbox, max_count=20000):
|
||||
min_lat, min_lon, max_lat, max_lon = bbox
|
||||
cell_key = self._bbox_to_cell_key(bbox)
|
||||
|
||||
# Check cache first
|
||||
if self.cache_db.is_cell_cached(cell_key, 'buildings'):
|
||||
return self.cache_db.query_buildings_bbox(
|
||||
min_lat, max_lat, min_lon, max_lon, max_count
|
||||
)
|
||||
|
||||
# Fetch from Overpass API
|
||||
buildings = self._fetch_from_overpass(bbox, 'buildings')
|
||||
|
||||
# Store in cache
|
||||
self.cache_db.insert_buildings(buildings, cell_key)
|
||||
|
||||
return buildings[:max_count]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Worker Memory Optimization
|
||||
|
||||
### 4.1 Per-Tile Building Loading
|
||||
|
||||
Workers receive only tile bbox and query buildings themselves (or receive pre-filtered list).
|
||||
|
||||
```python
|
||||
def _pool_worker_tiled(args):
|
||||
"""Worker that loads buildings for its tile only."""
|
||||
tile_bbox, terrain_shm_refs, config = args
|
||||
|
||||
# Load only buildings for this tile
|
||||
cache_db = OSMCacheDB()
|
||||
buildings = cache_db.query_buildings_bbox(*tile_bbox, limit=5000)
|
||||
|
||||
# Much smaller memory footprint per worker
|
||||
# ...rest of calculation
|
||||
```
|
||||
|
||||
### 4.2 Adaptive Worker Count
|
||||
|
||||
Reduce workers for large radius to prevent combined memory explosion.
|
||||
|
||||
```python
|
||||
def get_worker_count_for_radius(radius_m, base_workers):
|
||||
"""Scale down workers for large calculations."""
|
||||
if radius_m > 30000:
|
||||
return min(base_workers, 2)
|
||||
elif radius_m > 20000:
|
||||
return min(base_workers, 3)
|
||||
elif radius_m > 10000:
|
||||
return min(base_workers, 4)
|
||||
return base_workers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Frontend Progressive Rendering
|
||||
|
||||
### 5.1 Accumulate Partial Results
|
||||
|
||||
**File:** `frontend/src/store/coverage.ts`
|
||||
|
||||
```typescript
|
||||
interface CoverageState {
|
||||
points: CoveragePoint[];
|
||||
isCalculating: boolean;
|
||||
progress: number;
|
||||
// NEW:
|
||||
partialResults: CoveragePoint[];
|
||||
tilesCompleted: number;
|
||||
totalTiles: number;
|
||||
}
|
||||
|
||||
// Handle partial results
|
||||
case 'partial_results':
|
||||
set(state => ({
|
||||
partialResults: [...state.partialResults, ...message.points],
|
||||
progress: message.progress,
|
||||
tilesCompleted: state.tilesCompleted + 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
set(state => ({
|
||||
points: state.partialResults, // Finalize
|
||||
partialResults: [],
|
||||
isCalculating: false
|
||||
}));
|
||||
break;
|
||||
```
|
||||
|
||||
### 5.2 Incremental Heatmap Render
|
||||
|
||||
**File:** `frontend/src/components/map/CoverageHeatmap.tsx`
|
||||
|
||||
```typescript
|
||||
function CoverageHeatmap() {
|
||||
const { points, partialResults, isCalculating } = useCoverageStore();
|
||||
|
||||
// Show partial results while calculating
|
||||
const displayPoints = isCalculating ? partialResults : points;
|
||||
|
||||
// Throttle re-renders during streaming (every 500 points)
|
||||
const throttledPoints = useThrottle(displayPoints, 500);
|
||||
|
||||
return <HeatmapLayer points={throttledPoints} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Priority 1 — Biggest Impact
|
||||
1. **Tile-based processing** (Phase 2.1) — enables large radius
|
||||
2. **SQLite cache** (Phase 3) — reduces memory, speeds up repeated calcs
|
||||
|
||||
### Priority 2 — Memory Reduction
|
||||
3. **Terrain mmap** (Phase 1.1) — easy win, minimal code change
|
||||
4. **Per-tile building loading** (Phase 4.1)
|
||||
|
||||
### Priority 3 — UX Improvement
|
||||
5. **Progressive WebSocket** (Phase 2.2)
|
||||
6. **Frontend streaming** (Phase 5)
|
||||
|
||||
### Priority 4 — Polish
|
||||
7. **Terrain disk cache** (Phase 1.2)
|
||||
8. **Adaptive worker count** (Phase 4.2)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| Radius | Max Time | Max RAM |
|
||||
|--------|----------|---------|
|
||||
| 20 km | < 3 min | < 3 GB |
|
||||
| 30 km | < 5 min | < 3.5 GB |
|
||||
| 50 km | < 10 min | < 4 GB |
|
||||
|
||||
- No OOM crashes at any radius up to 50km
|
||||
- Progressive results visible within 30s of starting
|
||||
- Cache reuse speeds up repeated calculations 5-10x
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `terrain_service.py` | mmap loading, disk cache |
|
||||
| `coverage_service.py` | tile-based routing |
|
||||
| `parallel_coverage_service.py` | adaptive workers |
|
||||
| `osm_client.py` | SQLite integration |
|
||||
| `websocket.py` | streaming results |
|
||||
| **NEW** `tile_processor.py` | tile generation & processing |
|
||||
| **NEW** `cache_db.py` | SQLite cache layer |
|
||||
|
||||
### Frontend (TypeScript)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `store/coverage.ts` | partial results handling |
|
||||
| `CoverageHeatmap.tsx` | incremental rendering |
|
||||
| `App.tsx` | progress for tiled calc |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test 20km radius
|
||||
curl -X POST http://localhost:8888/api/coverage/calculate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"radius": 20000, "resolution": 500, "preset": "standard"}'
|
||||
|
||||
# Monitor memory
|
||||
watch -n 1 'ps aux | grep rfcp-server | awk "{print \$6/1024\" MB\"}"'
|
||||
|
||||
# Test 50km radius
|
||||
curl -X POST http://localhost:8888/api/coverage/calculate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"radius": 50000, "resolution": 1000, "preset": "standard"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tile size 5km is a balance — smaller = more overhead, larger = more memory
|
||||
- SQLite R-tree extension would be faster but requires compilation
|
||||
- For Rust version, all of this will be native and faster
|
||||
|
||||
---
|
||||
|
||||
*"Think in tiles, stream results, cache everything"* 🗺️
|
||||
@@ -1,233 +0,0 @@
|
||||
# RFCP Development Session Summary
|
||||
## Date: February 1, 2025 (actually 2026)
|
||||
## Status: Phase 3.0 Complete, Performance Optimization Ongoing
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Project Overview
|
||||
|
||||
**RFCP (Radio Frequency Coverage Planning)** — desktop application for tactical LTE network planning, part of UMTC (Ukrainian Military Tactical Communications) project.
|
||||
|
||||
**Tech Stack:**
|
||||
- Backend: Python/FastAPI + NumPy + ProcessPoolExecutor
|
||||
- Frontend: React + TypeScript + Vite
|
||||
- Desktop: Electron
|
||||
- Build: PyInstaller (backend), electron-builder (desktop)
|
||||
|
||||
**Goal:** Calculate RF coverage maps with terrain, buildings, vegetation analysis.
|
||||
|
||||
---
|
||||
|
||||
## ✅ What Works (Phase 3.0 Achievements)
|
||||
|
||||
### Performance
|
||||
| Preset | Before | After | Status |
|
||||
|--------|--------|-------|--------|
|
||||
| Standard (100-200m res) | 38s | **~5s** | ✅ EXCELLENT |
|
||||
| Detailed (300m, 5km) | timeout | timeout | ❌ Still broken |
|
||||
|
||||
### Architecture (48 new files, 82 tests)
|
||||
- ✅ Modular propagation models (8 models: FreeSpace, Okumura-Hata, COST-231, ITU-R P.1546, etc.)
|
||||
- ✅ SharedMemoryManager for terrain data (zero-copy, 25 MB)
|
||||
- ✅ Building filtering (351k → 27k bbox → 15k cap)
|
||||
- ✅ WebSocket progress streaming (backend works)
|
||||
- ✅ Clean model selection by frequency/environment
|
||||
- ✅ Worker cleanup on shutdown
|
||||
- ✅ Overpass API retry with failover (3 attempts, mirror endpoint)
|
||||
|
||||
### New Files Structure
|
||||
```
|
||||
backend/app/
|
||||
├── propagation/ # 8 model files
|
||||
├── geometry/ # 5 files (haversine, intersection, reflection, diffraction, los)
|
||||
├── core/ # 4 files (engine, grid, calculator, result)
|
||||
├── parallel/ # 3 files (manager, worker, pool)
|
||||
├── services/ # cache.py, osm_client.py
|
||||
├── utils/ # logging.py, progress.py, units.py
|
||||
└── api/websocket.py
|
||||
|
||||
frontend/src/
|
||||
├── hooks/useWebSocket.ts
|
||||
├── services/websocket.ts
|
||||
└── components/FrequencyBandPanel.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❌ Current Blockers
|
||||
|
||||
### 1. Detailed Preset Timeout (CRITICAL)
|
||||
|
||||
**Symptom:** 300s timeout, only 194/868 points calculated
|
||||
|
||||
**Latest test results:**
|
||||
```
|
||||
[DOMINANT_PATH_VEC] Point #1: buildings=30, walls=214, dist=4887m
|
||||
302.8ms/point × 868 points = 262 seconds
|
||||
```
|
||||
|
||||
**Root Cause Analysis:**
|
||||
- Early return fix (Claude Code) was for `buildings=[]` case
|
||||
- But in reality, buildings ARE present (15,000 after cap)
|
||||
- Each point finds 17-30 nearby buildings
|
||||
- Each building has 100-295 wall segments
|
||||
- **dominant_path_service** geometry calculations are expensive
|
||||
|
||||
**The real problem is NOT "buildings=0 is slow"**
|
||||
**The real problem IS "dominant_path with buildings is inherently slow"**
|
||||
|
||||
**Potential solutions:**
|
||||
1. Simplify building geometry (reduce wall count)
|
||||
2. Use spatial indexing more aggressively
|
||||
3. Skip dominant_path for distant points (>3km?)
|
||||
4. Reduce building query radius
|
||||
5. Use simpler path loss model when buildings present
|
||||
6. GPU acceleration (CuPy) for geometry
|
||||
|
||||
### 2. Progress Bar Stuck at "Initializing 5%"
|
||||
|
||||
**Symptom:** UI shows "Initializing 5%" forever
|
||||
|
||||
**Fix attempted:** `await asyncio.sleep(0)` after progress_fn() — not working
|
||||
|
||||
**Likely cause:** Frontend WebSocket connection or state update issue
|
||||
|
||||
### 3. App Close Broken
|
||||
|
||||
**Symptom:** Clicking X kills backend but frontend stays open
|
||||
|
||||
**Partial fix:** Worker cleanup works, but Electron window doesn't close
|
||||
|
||||
### 4. Memory Not Released
|
||||
|
||||
**Symptom:** 1328 MB not freed after calculation
|
||||
```
|
||||
Before: 3904 MB free
|
||||
After: 2576 MB free
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Performance Analysis
|
||||
|
||||
### Why Detailed is slow (the math):
|
||||
|
||||
```
|
||||
Points: 868
|
||||
Buildings nearby per point: ~25 average
|
||||
Walls per building: ~150 average
|
||||
Wall intersection checks: 868 × 25 × 150 = 3,255,000
|
||||
|
||||
At 0.1ms per check = 325 seconds
|
||||
```
|
||||
|
||||
### Why Standard is fast:
|
||||
|
||||
- Lower resolution = fewer points (~200 vs 868)
|
||||
- Likely skips some detailed calculations
|
||||
- Buildings still processed but fewer points to check
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Key Files to Review
|
||||
|
||||
### Backend (performance critical)
|
||||
```
|
||||
backend/app/services/
|
||||
├── dominant_path_service.py # THE BOTTLENECK
|
||||
├── coverage_service.py # Orchestration, progress
|
||||
├── parallel_coverage_service.py # Worker management
|
||||
└── buildings_service.py # OSM fetch, caching
|
||||
```
|
||||
|
||||
### Frontend (UI bugs)
|
||||
```
|
||||
frontend/src/
|
||||
├── App.tsx # Progress display
|
||||
├── store/coverage.ts # WebSocket state
|
||||
└── services/websocket.ts # WS connection
|
||||
```
|
||||
|
||||
### Desktop (close bug)
|
||||
```
|
||||
desktop/main.js # Electron lifecycle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Next Steps
|
||||
|
||||
### Priority 1: Fix Detailed Performance
|
||||
|
||||
**Option A: Aggressive spatial filtering**
|
||||
```python
|
||||
# In dominant_path_service.py
|
||||
# Only check buildings within line-of-sight corridor
|
||||
# Not all buildings within radius
|
||||
```
|
||||
|
||||
**Option B: LOD (Level of Detail)**
|
||||
```python
|
||||
# Distance > 2km: skip dominant path entirely
|
||||
# Distance 1-2km: simplified model
|
||||
# Distance < 1km: full calculation
|
||||
```
|
||||
|
||||
**Option C: Building simplification**
|
||||
```python
|
||||
# Reduce wall count per building
|
||||
# Merge adjacent buildings
|
||||
# Use bounding boxes instead of polygons for far buildings
|
||||
```
|
||||
|
||||
### Priority 2: Fix UI Bugs
|
||||
- Debug WebSocket in browser DevTools
|
||||
- Check Electron close handler
|
||||
|
||||
### Priority 3: Memory
|
||||
- Explicit cleanup after calculation
|
||||
- Check for leaked references
|
||||
|
||||
---
|
||||
|
||||
## 📝 Session Timeline
|
||||
|
||||
1. **Phase 2.4-2.5.1** — Vectorization attempt (didn't help)
|
||||
2. **Decision** — Full Phase 3.0 architecture refactor
|
||||
3. **Architecture Doc** — 1719 lines specification
|
||||
4. **Claude Code Round 1** — 48 files, 82 tests (35 min)
|
||||
5. **Integration Round** — WebSocket, progress, model selection (20 min)
|
||||
6. **Bug Fix Round** — Memory, workers, app close (15 min)
|
||||
7. **Claude Code Fix** — Dominant path early return, Overpass retry, progress (13 min)
|
||||
8. **Current** — Still timeout, need different approach
|
||||
|
||||
---
|
||||
|
||||
## 💡 Key Insights
|
||||
|
||||
1. **Vectorization alone doesn't help** — problem is algorithmic, not just numpy
|
||||
2. **SharedMemory works** — terrain in shared memory is efficient
|
||||
3. **Building count matters** — 351k→15k filtering helps but not enough
|
||||
4. **dominant_path is the bottleneck** — consistently 200-300ms/point
|
||||
5. **Standard preset proves architecture works** — fast when less work needed
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documents
|
||||
|
||||
- `/mnt/project/RFCP-Phase-3.0-Architecture-Refactor.md` — Full architecture spec
|
||||
- `/mnt/project/SESSION-2025-01-30-Iteration-10_1-Complete.md` — Previous session
|
||||
- `/mnt/transcripts/2026-02-01-19-06-32-phase-3.0-refactor-implementation-results.txt` — Detailed transcript
|
||||
|
||||
---
|
||||
|
||||
## 🎮 Side Project
|
||||
|
||||
During this session, also designed **DF Diplomacy Expanded** mod:
|
||||
- Design doc: `DF-Diplomacy-Expanded-Design-Doc.md` (1202 lines)
|
||||
- MVP: War score, peace negotiation, tribute, reputation
|
||||
- Motto: *"Losing is fun, but sometimes you want to lose diplomatically."*
|
||||
|
||||
---
|
||||
|
||||
*"Standard preset works beautifully. Detailed preset needs love. The architecture is solid — now we optimize."*
|
||||
@@ -8,7 +8,6 @@ progress updates during computation phases.
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
@@ -51,7 +50,7 @@ class ConnectionManager:
|
||||
"data": result,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"[WS] send_result failed: {e}")
|
||||
logger.warning(f"[WS] send_result failed: {e}")
|
||||
|
||||
async def send_error(self, ws: WebSocket, calc_id: str, error: str):
|
||||
try:
|
||||
@@ -61,7 +60,7 @@ class ConnectionManager:
|
||||
"message": error,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"[WS] send_error failed: {e}")
|
||||
logger.warning(f"[WS] send_error failed: {e}")
|
||||
|
||||
|
||||
ws_manager = ConnectionManager()
|
||||
@@ -74,14 +73,32 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
||||
|
||||
# Shared progress state — written by worker threads, polled by event loop.
|
||||
# Python GIL makes dict value assignment atomic for simple types.
|
||||
_progress = {"phase": "Initializing", "pct": 0.05, "seq": 0}
|
||||
_progress = {"phase": "Initializing", "pct": 0.0, "seq": 0}
|
||||
_done = False
|
||||
|
||||
# Get event loop for cross-thread scheduling of WS sends.
|
||||
loop = asyncio.get_running_loop()
|
||||
_last_direct_pct = 0.0
|
||||
_last_direct_phase = ""
|
||||
|
||||
def sync_progress_fn(phase: str, pct: float, _eta: Optional[float] = None):
|
||||
"""Thread-safe progress callback — just updates a shared dict."""
|
||||
"""Thread-safe progress callback — updates dict AND schedules direct WS send."""
|
||||
nonlocal _last_direct_pct, _last_direct_phase
|
||||
_progress["phase"] = phase
|
||||
_progress["pct"] = pct
|
||||
_progress["seq"] += 1
|
||||
# Schedule direct WS send via event loop (works from any thread).
|
||||
# Throttle: only send on phase change or >=2% progress.
|
||||
if phase != _last_direct_phase or pct - _last_direct_pct >= 0.02:
|
||||
_last_direct_pct = pct
|
||||
_last_direct_phase = phase
|
||||
try:
|
||||
loop.call_soon_threadsafe(
|
||||
asyncio.ensure_future,
|
||||
ws_manager.send_progress(ws, calc_id, phase, pct),
|
||||
)
|
||||
except RuntimeError:
|
||||
pass # Event loop closed
|
||||
|
||||
try:
|
||||
sites_data = data.get("sites", [])
|
||||
@@ -116,21 +133,27 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
||||
if primary_model.name not in models_used:
|
||||
models_used.insert(0, primary_model.name)
|
||||
|
||||
await ws_manager.send_progress(ws, calc_id, "Initializing", 0.05)
|
||||
await ws_manager.send_progress(ws, calc_id, "Initializing", 0.02)
|
||||
|
||||
# ── Progress poller: reads shared dict and sends WS updates ──
|
||||
# ── Backup progress poller: catches anything call_soon_threadsafe missed ──
|
||||
async def progress_poller():
|
||||
last_sent_seq = 0
|
||||
last_sent_pct = 0.0
|
||||
last_sent_phase = "Initializing"
|
||||
while not _done:
|
||||
await asyncio.sleep(0.3)
|
||||
await asyncio.sleep(0.5)
|
||||
seq = _progress["seq"]
|
||||
pct = _progress["pct"]
|
||||
phase = _progress["phase"]
|
||||
if seq != last_sent_seq and (pct - last_sent_pct >= 0.01 or phase != "Calculating coverage"):
|
||||
# Send on any phase change OR >=3% progress (primary sends handle fine-grained)
|
||||
if seq != last_sent_seq and (
|
||||
phase != last_sent_phase
|
||||
or pct - last_sent_pct >= 0.03
|
||||
):
|
||||
await ws_manager.send_progress(ws, calc_id, phase, pct)
|
||||
last_sent_seq = seq
|
||||
last_sent_pct = pct
|
||||
last_sent_phase = phase
|
||||
|
||||
poller_task = asyncio.create_task(progress_poller())
|
||||
|
||||
@@ -149,6 +172,7 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
||||
points = await asyncio.wait_for(
|
||||
coverage_service.calculate_multi_site_coverage(
|
||||
sites, settings, cancel_token,
|
||||
progress_fn=sync_progress_fn,
|
||||
),
|
||||
timeout=300.0,
|
||||
)
|
||||
@@ -170,7 +194,6 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
||||
# Stop poller and send final progress
|
||||
_done = True
|
||||
await poller_task
|
||||
await ws_manager.send_progress(ws, calc_id, "Finalizing", 0.98)
|
||||
|
||||
computation_time = time.time() - start_time
|
||||
|
||||
@@ -201,7 +224,10 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
||||
"models_used": models_used,
|
||||
}
|
||||
|
||||
# Send "Complete" before result so frontend shows 100%
|
||||
await ws_manager.send_progress(ws, calc_id, "Complete", 1.0)
|
||||
await ws_manager.send_result(ws, calc_id, result)
|
||||
logger.info(f"[WS] calc={calc_id} done: {len(points)} pts, {computation_time:.1f}s")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[WS] Calculation error: {e}", exc_info=True)
|
||||
|
||||
@@ -485,7 +485,16 @@ class CoverageService:
|
||||
)
|
||||
streets = _filter_osm_list_to_bbox(streets, min_lat, min_lon, max_lat, max_lon)
|
||||
water_bodies = _filter_osm_list_to_bbox(water_bodies, min_lat, min_lon, max_lat, max_lon)
|
||||
vegetation_areas = _filter_osm_list_to_bbox(vegetation_areas, min_lat, min_lon, max_lat, max_lon)
|
||||
# Cap vegetation at 5000 — each area requires O(samples × areas)
|
||||
# point-in-polygon checks per grid point. 20k+ areas with dominant
|
||||
# path enabled causes OOM via worker memory explosion.
|
||||
vegetation_areas = _filter_osm_list_to_bbox(
|
||||
vegetation_areas, min_lat, min_lon, max_lat, max_lon,
|
||||
max_count=5000,
|
||||
)
|
||||
|
||||
_clog(f"Filtered OSM data: {len(buildings)} bldgs, {len(streets)} streets, "
|
||||
f"{len(water_bodies)} water, {len(vegetation_areas)} veg")
|
||||
|
||||
# Build spatial index for buildings
|
||||
spatial_idx: Optional[SpatialIndex] = None
|
||||
@@ -650,10 +659,13 @@ class CoverageService:
|
||||
sites: List[SiteParams],
|
||||
settings: CoverageSettings,
|
||||
cancel_token: Optional[CancellationToken] = None,
|
||||
progress_fn: Optional[Callable[[str, float], None]] = None,
|
||||
) -> List[CoveragePoint]:
|
||||
"""
|
||||
Calculate combined coverage from multiple sites
|
||||
Best server (strongest signal) wins at each point
|
||||
|
||||
progress_fn(phase, pct): optional callback for progress updates (0.0-1.0).
|
||||
"""
|
||||
if not sites:
|
||||
return []
|
||||
@@ -661,10 +673,26 @@ class CoverageService:
|
||||
# Apply preset once
|
||||
settings = apply_preset(settings)
|
||||
|
||||
# Per-site progress tracking for averaged overall progress
|
||||
num_sites = len(sites)
|
||||
_site_progress = [0.0] * num_sites
|
||||
|
||||
def _make_site_progress(idx: int):
|
||||
"""Create a progress_fn for one site that reports scaled overall progress."""
|
||||
def _site_fn(phase: str, pct: float, _eta=None):
|
||||
_site_progress[idx] = pct
|
||||
if progress_fn:
|
||||
overall = sum(_site_progress) / num_sites
|
||||
progress_fn(f"Site {idx + 1}/{num_sites}: {phase}", overall)
|
||||
return _site_fn
|
||||
|
||||
# Get all individual coverages
|
||||
all_coverages = await asyncio.gather(*[
|
||||
self.calculate_coverage(site, settings, cancel_token)
|
||||
for site in sites
|
||||
self.calculate_coverage(
|
||||
site, settings, cancel_token,
|
||||
progress_fn=_make_site_progress(i) if progress_fn else None,
|
||||
)
|
||||
for i, site in enumerate(sites)
|
||||
])
|
||||
|
||||
# Combine by best signal
|
||||
@@ -751,7 +779,8 @@ class CoverageService:
|
||||
points = []
|
||||
timing = {"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0}
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0}
|
||||
total = len(grid)
|
||||
log_interval = max(1, total // 20)
|
||||
|
||||
@@ -901,7 +930,6 @@ class CoverageService:
|
||||
|
||||
# LOD_NONE: skip dominant path entirely for distant points (>3km)
|
||||
if lod == LODLevel.NONE:
|
||||
timing.setdefault("lod_none", 0)
|
||||
timing["lod_none"] += 1
|
||||
else:
|
||||
t0 = time.time()
|
||||
@@ -909,12 +937,10 @@ class CoverageService:
|
||||
# LOD_SIMPLIFIED: limit buildings for mid-range points (1.5-3km)
|
||||
dp_buildings = nearby_buildings
|
||||
if lod == LODLevel.SIMPLIFIED:
|
||||
timing.setdefault("lod_simplified", 0)
|
||||
timing["lod_simplified"] += 1
|
||||
if len(nearby_buildings) > SIMPLIFIED_MAX_BUILDINGS:
|
||||
dp_buildings = nearby_buildings[:SIMPLIFIED_MAX_BUILDINGS]
|
||||
else:
|
||||
timing.setdefault("lod_full", 0)
|
||||
timing["lod_full"] += 1
|
||||
|
||||
# nearby_buildings already filtered via spatial index —
|
||||
|
||||
@@ -164,11 +164,16 @@ except ImportError:
|
||||
ray = None # type: ignore
|
||||
|
||||
|
||||
# ── Worker-level spatial index cache (persists across tasks in same worker) ──
|
||||
# ── Worker-level caches (persist across tasks in same worker process) ──
|
||||
|
||||
_worker_spatial_idx = None
|
||||
_worker_cache_key: Optional[str] = None
|
||||
|
||||
# Shared-memory buildings/OSM — unpickled once per worker, cached by key
|
||||
_worker_shared_buildings = None
|
||||
_worker_shared_osm_data = None
|
||||
_worker_shared_data_key: Optional[str] = None
|
||||
|
||||
|
||||
def _ray_process_chunk_impl(chunk, terrain_cache, buildings, osm_data, config):
|
||||
"""Implementation: process a chunk of (lat, lon, elevation) tuples.
|
||||
@@ -205,6 +210,7 @@ def _ray_process_chunk_impl(chunk, terrain_cache, buildings, osm_data, config):
|
||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0,
|
||||
}
|
||||
|
||||
precomputed = config.get('precomputed')
|
||||
@@ -238,9 +244,14 @@ if RAY_AVAILABLE:
|
||||
|
||||
|
||||
def get_cpu_count() -> int:
|
||||
"""Get number of usable CPU cores, capped at 14."""
|
||||
"""Get number of usable CPU cores, capped at 6.
|
||||
|
||||
Each worker holds its own copy of buildings + OSM data + spatial index
|
||||
(~200-400 MB per worker). Capping at 6 prevents OOM on systems with
|
||||
8-16 GB RAM (especially WSL2 with limited memory allocation).
|
||||
"""
|
||||
try:
|
||||
return min(mp.cpu_count() or 4, 14)
|
||||
return min(mp.cpu_count() or 4, 6)
|
||||
except Exception:
|
||||
return 4
|
||||
|
||||
@@ -327,8 +338,25 @@ def calculate_coverage_parallel(
|
||||
except Exception as e:
|
||||
log_fn(f"Ray execution failed: {e} — falling back to sequential")
|
||||
|
||||
# Fallback: ProcessPoolExecutor with reduced workers to avoid MemoryError
|
||||
pool_workers = min(num_workers, 6)
|
||||
# Fallback: ProcessPoolExecutor (shared memory eliminates per-chunk pickle)
|
||||
pool_workers = num_workers
|
||||
|
||||
# Scale workers down based on data volume to prevent OOM.
|
||||
# Each worker unpickles + holds its own copy of buildings, OSM data, and
|
||||
# spatial index. With large datasets the per-worker memory can exceed
|
||||
# 300 MB, so reduce workers to keep total under ~2 GB.
|
||||
data_items = len(buildings) + len(streets) + len(water_bodies) + len(vegetation_areas)
|
||||
if data_items > 20000:
|
||||
pool_workers = min(pool_workers, 2)
|
||||
log_fn(f"Data volume high ({data_items} items) — capping workers at {pool_workers}")
|
||||
elif data_items > 10000:
|
||||
pool_workers = min(pool_workers, 3)
|
||||
log_fn(f"Data volume moderate ({data_items} items) — capping workers at {pool_workers}")
|
||||
elif data_items > 5000:
|
||||
pool_workers = min(pool_workers, 4)
|
||||
log_fn(f"Data volume elevated ({data_items} items) — capping workers at {pool_workers}")
|
||||
|
||||
log_fn(f"ProcessPool: {pool_workers} workers (cpu_count={num_workers}, data_items={data_items})")
|
||||
if pool_workers > 1 and total_points > 100:
|
||||
try:
|
||||
return _calculate_with_process_pool(
|
||||
@@ -338,6 +366,8 @@ def calculate_coverage_parallel(
|
||||
pool_workers, log_fn, cancel_token, precomputed,
|
||||
progress_fn,
|
||||
)
|
||||
except (MemoryError, OSError) as e:
|
||||
log_fn(f"ProcessPool OOM/OS error: {e} — falling back to sequential")
|
||||
except Exception as e:
|
||||
log_fn(f"ProcessPool failed: {e} — falling back to sequential")
|
||||
|
||||
@@ -396,8 +426,8 @@ def _calculate_with_ray(
|
||||
for lat, lon in grid
|
||||
]
|
||||
|
||||
# ~4 chunks per worker for granular progress
|
||||
chunk_size = max(1, len(items) // (num_workers * 4))
|
||||
# Larger chunks to amortize IPC overhead (was num_workers*4)
|
||||
chunk_size = max(1, min(400, len(items) // max(2, num_workers)))
|
||||
chunks = [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
|
||||
log_fn(f"Submitting {len(chunks)} chunks of ~{chunk_size} points")
|
||||
|
||||
@@ -489,6 +519,7 @@ def _pool_worker_process_chunk(args):
|
||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0,
|
||||
}
|
||||
|
||||
precomputed = config.get('precomputed')
|
||||
@@ -542,6 +573,28 @@ def _store_terrain_in_shm(terrain_cache: Dict[str, np.ndarray], log_fn) -> Tuple
|
||||
return blocks, refs
|
||||
|
||||
|
||||
def _store_pickle_in_shm(data, label: str, log_fn) -> Tuple[Optional[Any], Optional[dict]]:
|
||||
"""Pickle arbitrary data into a SharedMemory block.
|
||||
|
||||
Returns (shm_block, ref_dict) where ref_dict = {shm_name, size}.
|
||||
On failure returns (None, None) and caller should fall back to pickle.
|
||||
"""
|
||||
import multiprocessing.shared_memory as shm_mod
|
||||
import pickle
|
||||
|
||||
try:
|
||||
blob = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
size = len(blob)
|
||||
block = shm_mod.SharedMemory(create=True, size=size)
|
||||
block.buf[:size] = blob
|
||||
mb = size / (1024 * 1024)
|
||||
log_fn(f"{label} in shared memory: {mb:.1f} MB")
|
||||
return block, {'shm_name': block.name, 'size': size}
|
||||
except Exception as e:
|
||||
log_fn(f"Failed to store {label} in shm: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _pool_worker_shm_chunk(args):
|
||||
"""Worker function that reads terrain from shared memory instead of pickle."""
|
||||
import multiprocessing.shared_memory as shm_mod
|
||||
@@ -585,6 +638,7 @@ def _pool_worker_shm_chunk(args):
|
||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0,
|
||||
}
|
||||
|
||||
precomputed = config.get('precomputed')
|
||||
@@ -607,6 +661,200 @@ def _pool_worker_shm_chunk(args):
|
||||
return results
|
||||
|
||||
|
||||
_worker_chunk_count: int = 0 # per-worker chunk counter
|
||||
|
||||
|
||||
def _pool_worker_shm_shared(args):
|
||||
"""Worker: terrain + buildings + OSM all via shared memory.
|
||||
|
||||
Per-chunk args are tiny (~8 KB): just point coords, shm refs, and config.
|
||||
Buildings and OSM data are unpickled from shared memory ONCE per worker
|
||||
and cached in module globals for subsequent chunks.
|
||||
"""
|
||||
import multiprocessing.shared_memory as shm_mod
|
||||
import pickle
|
||||
|
||||
global _worker_chunk_count
|
||||
_worker_chunk_count += 1
|
||||
pid = os.getpid()
|
||||
t_worker_start = time.perf_counter()
|
||||
|
||||
chunk, terrain_shm_refs, shared_data_refs, config = args
|
||||
|
||||
# ── Reconstruct terrain from shared memory ──
|
||||
t0 = time.perf_counter()
|
||||
terrain_cache = {}
|
||||
for tile_name, ref in terrain_shm_refs.items():
|
||||
try:
|
||||
block = shm_mod.SharedMemory(name=ref['shm_name'])
|
||||
terrain_cache[tile_name] = np.ndarray(
|
||||
ref['shape'], dtype=ref['dtype'], buffer=block.buf,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from app.services.terrain_service import terrain_service
|
||||
terrain_service._tile_cache = terrain_cache
|
||||
t_terrain_shm = time.perf_counter() - t0
|
||||
|
||||
# ── Read buildings + OSM from shared memory (cached per worker) ──
|
||||
global _worker_shared_buildings, _worker_shared_osm_data, _worker_shared_data_key
|
||||
global _worker_spatial_idx, _worker_cache_key
|
||||
|
||||
data_key = config.get('cache_key', '')
|
||||
cached = (_worker_shared_data_key == data_key)
|
||||
|
||||
t_unpickle_bld = 0.0
|
||||
t_unpickle_osm = 0.0
|
||||
t_spatial = 0.0
|
||||
|
||||
if not cached:
|
||||
# First chunk for this calculation — unpickle from shm
|
||||
buildings_ref = shared_data_refs.get('buildings')
|
||||
osm_ref = shared_data_refs.get('osm_data')
|
||||
|
||||
if buildings_ref:
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
blk = shm_mod.SharedMemory(name=buildings_ref['shm_name'])
|
||||
_worker_shared_buildings = pickle.loads(bytes(blk.buf[:buildings_ref['size']]))
|
||||
t_unpickle_bld = time.perf_counter() - t0
|
||||
except Exception:
|
||||
_worker_shared_buildings = []
|
||||
else:
|
||||
_worker_shared_buildings = []
|
||||
|
||||
if osm_ref:
|
||||
try:
|
||||
t0 = time.perf_counter()
|
||||
blk = shm_mod.SharedMemory(name=osm_ref['shm_name'])
|
||||
_worker_shared_osm_data = pickle.loads(bytes(blk.buf[:osm_ref['size']]))
|
||||
t_unpickle_osm = time.perf_counter() - t0
|
||||
except Exception:
|
||||
_worker_shared_osm_data = {}
|
||||
else:
|
||||
_worker_shared_osm_data = {}
|
||||
|
||||
_worker_shared_data_key = data_key
|
||||
|
||||
# Rebuild spatial index for new data
|
||||
t0 = time.perf_counter()
|
||||
if _worker_shared_buildings:
|
||||
from app.services.spatial_index import SpatialIndex
|
||||
_worker_spatial_idx = SpatialIndex()
|
||||
_worker_spatial_idx.build(_worker_shared_buildings)
|
||||
else:
|
||||
_worker_spatial_idx = None
|
||||
_worker_cache_key = data_key
|
||||
t_spatial = time.perf_counter() - t0
|
||||
|
||||
print(
|
||||
f"[WORKER {pid}] Init: terrain_shm={t_terrain_shm*1000:.1f}ms "
|
||||
f"unpickle_bld={t_unpickle_bld*1000:.1f}ms "
|
||||
f"unpickle_osm={t_unpickle_osm*1000:.1f}ms "
|
||||
f"spatial={t_spatial*1000:.1f}ms "
|
||||
f"buildings={len(_worker_shared_buildings or [])} "
|
||||
f"tiles={len(terrain_cache)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[WORKER {pid}] Processing chunk {_worker_chunk_count}, "
|
||||
f"cached={cached}, points={len(chunk)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
buildings = _worker_shared_buildings or []
|
||||
osm_data = _worker_shared_osm_data or {}
|
||||
|
||||
# ── Imports + object creation (timed) ──
|
||||
t0 = time.perf_counter()
|
||||
from app.services.coverage_service import CoverageService, SiteParams, CoverageSettings
|
||||
t_import = time.perf_counter() - t0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
site = SiteParams(**config['site_dict'])
|
||||
settings = CoverageSettings(**config['settings_dict'])
|
||||
svc = CoverageService()
|
||||
t_pydantic = time.perf_counter() - t0
|
||||
|
||||
timing = {
|
||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0,
|
||||
}
|
||||
|
||||
precomputed = config.get('precomputed')
|
||||
|
||||
streets = osm_data.get('streets', [])
|
||||
water = osm_data.get('water_bodies', [])
|
||||
veg = osm_data.get('vegetation_areas', [])
|
||||
site_elev = config['site_elevation']
|
||||
|
||||
t_init_done = time.perf_counter()
|
||||
init_ms = (t_init_done - t_worker_start) * 1000
|
||||
|
||||
# ── Process points with per-point profiling (first 3 only) ──
|
||||
results = []
|
||||
t_loop_start = time.perf_counter()
|
||||
t_model_dump_total = 0.0
|
||||
n_dumped = 0
|
||||
|
||||
for i, (lat, lon, point_elev) in enumerate(chunk):
|
||||
pre = precomputed.get((lat, lon)) if precomputed else None
|
||||
|
||||
# Snapshot timing dict before call (for first 3 points)
|
||||
if i < 3:
|
||||
timing_before = {k: v for k, v in timing.items()}
|
||||
t_pt = time.perf_counter()
|
||||
|
||||
point = svc._calculate_point_sync(
|
||||
site, lat, lon, settings,
|
||||
buildings, streets,
|
||||
_worker_spatial_idx, water, veg,
|
||||
site_elev, point_elev, timing,
|
||||
precomputed_distance=pre.get('distance') if pre else None,
|
||||
precomputed_path_loss=pre.get('path_loss') if pre else None,
|
||||
)
|
||||
|
||||
if i < 3:
|
||||
t_pt_done = time.perf_counter()
|
||||
pt_ms = (t_pt_done - t_pt) * 1000
|
||||
deltas = {k: (timing[k] - timing_before.get(k, 0)) * 1000 for k in timing}
|
||||
parts = " ".join(f"{k}={v:.2f}" for k, v in deltas.items() if v > 0.001)
|
||||
print(
|
||||
f"[WORKER {pid}] Point {i}: {pt_ms:.2f}ms "
|
||||
f"rsrp={point.rsrp:.1f} dist={point.distance:.0f}m "
|
||||
f"breakdown=[{parts}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if point.rsrp >= settings.min_signal:
|
||||
t_md = time.perf_counter()
|
||||
results.append(point.model_dump())
|
||||
t_model_dump_total += time.perf_counter() - t_md
|
||||
n_dumped += 1
|
||||
|
||||
t_loop_done = time.perf_counter()
|
||||
loop_ms = (t_loop_done - t_loop_start) * 1000
|
||||
total_ms = (t_loop_done - t_worker_start) * 1000
|
||||
avg_pt = loop_ms / len(chunk) if chunk else 0
|
||||
avg_dump = (t_model_dump_total * 1000 / n_dumped) if n_dumped else 0
|
||||
|
||||
print(
|
||||
f"[WORKER {pid}] Chunk done: total={total_ms:.0f}ms "
|
||||
f"init={init_ms:.0f}ms loop={loop_ms:.0f}ms "
|
||||
f"avg_pt={avg_pt:.2f}ms model_dump={avg_dump:.2f}ms×{n_dumped} "
|
||||
f"import={t_import*1000:.1f}ms pydantic={t_pydantic*1000:.1f}ms "
|
||||
f"terrain_shm={t_terrain_shm*1000:.1f}ms "
|
||||
f"results={len(results)}/{len(chunk)}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _calculate_with_process_pool(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
terrain_cache, buildings, streets, water_bodies,
|
||||
@@ -616,23 +864,28 @@ def _calculate_with_process_pool(
|
||||
):
|
||||
"""Execute using ProcessPoolExecutor.
|
||||
|
||||
Uses shared memory for terrain tiles (zero-copy numpy views) to reduce
|
||||
memory usage compared to pickling full terrain arrays per worker.
|
||||
Uses shared memory for terrain tiles (zero-copy numpy views), buildings,
|
||||
and OSM data (pickle-once, read-many) to eliminate per-chunk serialization
|
||||
overhead.
|
||||
"""
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
|
||||
total_points = len(grid)
|
||||
|
||||
# Estimate pickle size for building data and cap workers accordingly
|
||||
building_count = len(buildings)
|
||||
if building_count > 10000:
|
||||
num_workers = min(num_workers, 3)
|
||||
log_fn(f"Large building set ({building_count}) — reducing workers to {num_workers}")
|
||||
elif building_count > 5000:
|
||||
num_workers = min(num_workers, 4)
|
||||
data_items = building_count + len(streets) + len(water_bodies) + len(vegetation_areas)
|
||||
|
||||
log_fn(f"ProcessPool mode: {total_points} points, {num_workers} workers, "
|
||||
f"{building_count} buildings")
|
||||
f"{building_count} buildings, {data_items} total OSM items")
|
||||
|
||||
# Log memory at start
|
||||
try:
|
||||
with open('/proc/self/status') as f:
|
||||
for line in f:
|
||||
if line.startswith('VmRSS:'):
|
||||
log_fn(f"Memory before calculation: {line.strip()}")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Store terrain tiles in shared memory
|
||||
shm_blocks = []
|
||||
@@ -652,12 +905,31 @@ def _calculate_with_process_pool(
|
||||
log_fn(f"Shared memory setup failed ({e}), using pickle fallback")
|
||||
use_shm = False
|
||||
|
||||
# Store buildings + OSM data in shared memory (pickle once, read many)
|
||||
shared_data_refs = {}
|
||||
if use_shm:
|
||||
bld_block, bld_ref = _store_pickle_in_shm(buildings, "Buildings", log_fn)
|
||||
if bld_block:
|
||||
shm_blocks.append(bld_block)
|
||||
shared_data_refs['buildings'] = bld_ref
|
||||
|
||||
osm_data_dict = {
|
||||
'streets': streets,
|
||||
'water_bodies': water_bodies,
|
||||
'vegetation_areas': vegetation_areas,
|
||||
}
|
||||
osm_block, osm_ref = _store_pickle_in_shm(osm_data_dict, "OSM data", log_fn)
|
||||
if osm_block:
|
||||
shm_blocks.append(osm_block)
|
||||
shared_data_refs['osm_data'] = osm_ref
|
||||
|
||||
items = [
|
||||
(lat, lon, point_elevations.get((lat, lon), 0.0))
|
||||
for lat, lon in grid
|
||||
]
|
||||
|
||||
chunk_size = max(1, len(items) // (num_workers * 2))
|
||||
# Target larger chunks to amortize IPC overhead (was num_workers*2)
|
||||
chunk_size = max(1, min(400, len(items) // max(2, num_workers)))
|
||||
chunks = [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
|
||||
log_fn(f"Submitting {len(chunks)} chunks of ~{chunk_size} points")
|
||||
|
||||
@@ -685,8 +957,21 @@ def _calculate_with_process_pool(
|
||||
pool = ProcessPoolExecutor(max_workers=num_workers, mp_context=ctx)
|
||||
_set_active_pool(pool)
|
||||
|
||||
if use_shm:
|
||||
# Shared memory path: pass shm refs instead of terrain data
|
||||
if use_shm and shared_data_refs:
|
||||
# Full shared memory path: terrain + buildings + OSM all via shm
|
||||
worker_fn = _pool_worker_shm_shared
|
||||
futures = {
|
||||
pool.submit(
|
||||
worker_fn,
|
||||
(chunk, terrain_shm_refs, shared_data_refs, config),
|
||||
): i
|
||||
for i, chunk in enumerate(chunks)
|
||||
}
|
||||
elif use_shm and data_items <= 2000:
|
||||
# Terrain-only shm — buildings/OSM pickled per chunk.
|
||||
# Only safe for small datasets; large datasets would OOM from
|
||||
# pickle copies (num_chunks × pickle_size).
|
||||
log_fn(f"Terrain-only shm (small data: {data_items} items)")
|
||||
worker_fn = _pool_worker_shm_chunk
|
||||
futures = {
|
||||
pool.submit(
|
||||
@@ -695,8 +980,9 @@ def _calculate_with_process_pool(
|
||||
): i
|
||||
for i, chunk in enumerate(chunks)
|
||||
}
|
||||
else:
|
||||
# Pickle fallback path
|
||||
elif data_items <= 2000:
|
||||
# Full pickle fallback — only safe for small datasets
|
||||
log_fn(f"Full pickle path (small data: {data_items} items)")
|
||||
futures = {
|
||||
pool.submit(
|
||||
_pool_worker_process_chunk,
|
||||
@@ -704,6 +990,14 @@ def _calculate_with_process_pool(
|
||||
): i
|
||||
for i, chunk in enumerate(chunks)
|
||||
}
|
||||
else:
|
||||
# Large dataset + shared memory failed → per-chunk pickle would OOM.
|
||||
# Bail out; caller will fall back to sequential.
|
||||
log_fn(f"Shared memory failed for large dataset ({data_items} items) "
|
||||
f"— skipping ProcessPool to avoid OOM")
|
||||
raise MemoryError(
|
||||
f"Cannot safely pickle {data_items} OSM items per chunk"
|
||||
)
|
||||
|
||||
completed_chunks = 0
|
||||
for future in as_completed(futures):
|
||||
@@ -730,6 +1024,9 @@ def _calculate_with_process_pool(
|
||||
if progress_fn:
|
||||
progress_fn("Calculating coverage", 0.40 + 0.55 * (completed_chunks / len(chunks)))
|
||||
|
||||
except MemoryError:
|
||||
raise # Propagate to caller for sequential fallback
|
||||
|
||||
except Exception as e:
|
||||
log_fn(f"ProcessPool error: {e}")
|
||||
|
||||
@@ -748,8 +1045,22 @@ def _calculate_with_process_pool(
|
||||
block.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
# Release large local references before GC
|
||||
chunks = None # noqa: F841
|
||||
items = None # noqa: F841
|
||||
osm_data = None # noqa: F841
|
||||
shared_data_refs = None # noqa: F841
|
||||
# Force garbage collection to release memory from workers
|
||||
gc.collect()
|
||||
# Log memory after cleanup
|
||||
try:
|
||||
with open('/proc/self/status') as f:
|
||||
for line in f:
|
||||
if line.startswith('VmRSS:'):
|
||||
log_fn(f"Memory after cleanup: {line.strip()}")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
calc_time = time.time() - t_calc
|
||||
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
|
||||
@@ -758,7 +1069,11 @@ def _calculate_with_process_pool(
|
||||
timing = {
|
||||
"parallel_total": calc_time,
|
||||
"workers": num_workers,
|
||||
"backend": "process_pool" + ("/shm" if use_shm else "/pickle"),
|
||||
"backend": "process_pool" + (
|
||||
"/shm_full" if (use_shm and shared_data_refs)
|
||||
else "/shm_terrain" if use_shm
|
||||
else "/pickle"
|
||||
),
|
||||
}
|
||||
return all_results, timing
|
||||
|
||||
@@ -791,6 +1106,7 @@ def _calculate_sequential(
|
||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0,
|
||||
"lod_none": 0, "lod_simplified": 0, "lod_full": 0,
|
||||
}
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
# RFCP - Iteration 3.3.0: Performance Architecture Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
Major refactoring based on research into professional RF tools (Signal-Server, SPLAT!, CloudRF SLEIPNIR, Sionna RT).
|
||||
|
||||
**Root cause identified:** Pickle serialization overhead dominates computation time.
|
||||
- DP_TIMING shows: 0.6-0.9ms per point (actual calculation)
|
||||
- Real throughput: 258ms per point
|
||||
- **99% of time is IPC overhead, not calculation!**
|
||||
|
||||
**Target:** Reduce 5km Detailed from timeout (300s) to <30s
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Eliminate Pickle Overhead (CRITICAL)
|
||||
|
||||
### 1.1 Shared Memory for Buildings
|
||||
|
||||
Currently terrain is in shared memory, but **15,000 buildings are pickled for every chunk**.
|
||||
|
||||
**File:** `backend/app/services/parallel_coverage_service.py`
|
||||
|
||||
```python
|
||||
from multiprocessing import shared_memory
|
||||
import numpy as np
|
||||
|
||||
def buildings_to_shared_memory(buildings: list) -> tuple:
|
||||
"""
|
||||
Convert buildings to numpy arrays and store in shared memory.
|
||||
|
||||
Returns: (shm_name, shape, dtype) for reconstruction in workers
|
||||
"""
|
||||
# Extract building data into numpy arrays
|
||||
# For each building we need: lat, lon, height, num_vertices, vertices_flat
|
||||
|
||||
# Simplified: store as structured array
|
||||
building_data = []
|
||||
all_vertices = []
|
||||
vertex_offsets = [0]
|
||||
|
||||
for b in buildings:
|
||||
coords = extract_coords(b)
|
||||
height = b.get('properties', {}).get('height', 10.0)
|
||||
|
||||
building_data.append({
|
||||
'lat': np.mean([c[1] for c in coords]),
|
||||
'lon': np.mean([c[0] for c in coords]),
|
||||
'height': height,
|
||||
'vertex_start': len(all_vertices),
|
||||
'vertex_count': len(coords)
|
||||
})
|
||||
all_vertices.extend(coords)
|
||||
vertex_offsets.append(len(all_vertices))
|
||||
|
||||
# Create numpy arrays
|
||||
buildings_arr = np.array([
|
||||
(b['lat'], b['lon'], b['height'], b['vertex_start'], b['vertex_count'])
|
||||
for b in building_data
|
||||
], dtype=[
|
||||
('lat', 'f8'), ('lon', 'f8'), ('height', 'f4'),
|
||||
('vertex_start', 'i4'), ('vertex_count', 'i2')
|
||||
])
|
||||
|
||||
vertices_arr = np.array(all_vertices, dtype=[('lon', 'f8'), ('lat', 'f8')])
|
||||
|
||||
# Store in shared memory
|
||||
shm_buildings = shared_memory.SharedMemory(
|
||||
create=True,
|
||||
size=buildings_arr.nbytes,
|
||||
name=f"rfcp_buildings_{os.getpid()}"
|
||||
)
|
||||
shm_vertices = shared_memory.SharedMemory(
|
||||
create=True,
|
||||
size=vertices_arr.nbytes,
|
||||
name=f"rfcp_vertices_{os.getpid()}"
|
||||
)
|
||||
|
||||
# Copy data
|
||||
np.ndarray(buildings_arr.shape, dtype=buildings_arr.dtype,
|
||||
buffer=shm_buildings.buf)[:] = buildings_arr
|
||||
np.ndarray(vertices_arr.shape, dtype=vertices_arr.dtype,
|
||||
buffer=shm_vertices.buf)[:] = vertices_arr
|
||||
|
||||
return {
|
||||
'buildings': (shm_buildings.name, buildings_arr.shape, buildings_arr.dtype),
|
||||
'vertices': (shm_vertices.name, vertices_arr.shape, vertices_arr.dtype)
|
||||
}
|
||||
|
||||
|
||||
def buildings_from_shared_memory(shm_info: dict) -> tuple:
|
||||
"""Reconstruct buildings arrays from shared memory in worker."""
|
||||
shm_b = shared_memory.SharedMemory(name=shm_info['buildings'][0])
|
||||
shm_v = shared_memory.SharedMemory(name=shm_info['vertices'][0])
|
||||
|
||||
buildings = np.ndarray(
|
||||
shm_info['buildings'][1],
|
||||
dtype=shm_info['buildings'][2],
|
||||
buffer=shm_b.buf
|
||||
)
|
||||
vertices = np.ndarray(
|
||||
shm_info['vertices'][1],
|
||||
dtype=shm_info['vertices'][2],
|
||||
buffer=shm_v.buf
|
||||
)
|
||||
|
||||
return buildings, vertices, shm_b, shm_v
|
||||
```
|
||||
|
||||
### 1.2 Increase Batch Size
|
||||
|
||||
**Current:** 7 chunks of ~144 points = high IPC overhead per point
|
||||
**Target:** 2-3 chunks of ~300-400 points = amortize IPC cost
|
||||
|
||||
```python
|
||||
# In parallel_coverage_service.py
|
||||
def calculate_optimal_chunk_size(total_points: int, num_workers: int) -> int:
|
||||
"""
|
||||
Calculate chunk size to minimize IPC overhead.
|
||||
|
||||
Rule: computation_time should be 10-100x serialization_time
|
||||
For RF calculations: ~1ms compute, ~50ms serialize
|
||||
So batch at least 500 points to make compute dominate.
|
||||
"""
|
||||
min_chunk = 300 # Minimum to amortize IPC
|
||||
max_chunk = 1000 # Maximum for memory
|
||||
|
||||
ideal_chunks = max(2, num_workers) # At least 2 chunks per worker
|
||||
ideal_size = total_points // ideal_chunks
|
||||
|
||||
return max(min_chunk, min(max_chunk, ideal_size))
|
||||
```
|
||||
|
||||
### 1.3 Pre-build Spatial Index Once
|
||||
|
||||
Currently spatial index may be rebuilt per-chunk. Build once and share reference.
|
||||
|
||||
```python
|
||||
class SharedSpatialIndex:
|
||||
"""Spatial index that can be shared across processes via shared memory."""
|
||||
|
||||
def __init__(self, buildings_shm_info: dict):
|
||||
self.buildings, self.vertices, _, _ = buildings_from_shared_memory(buildings_shm_info)
|
||||
self._build_grid()
|
||||
|
||||
def _build_grid(self):
|
||||
"""Build simple grid-based spatial index."""
|
||||
# Grid cells of ~100m
|
||||
self.cell_size = 0.001 # ~111m in degrees
|
||||
self.grid = defaultdict(list)
|
||||
|
||||
for i, b in enumerate(self.buildings):
|
||||
cell_x = int(b['lon'] / self.cell_size)
|
||||
cell_y = int(b['lat'] / self.cell_size)
|
||||
self.grid[(cell_x, cell_y)].append(i)
|
||||
|
||||
def query_radius(self, lat: float, lon: float, radius_m: float) -> list:
|
||||
"""Get building indices within radius."""
|
||||
radius_deg = radius_m / 111000
|
||||
cells_to_check = int(radius_deg / self.cell_size) + 1
|
||||
|
||||
center_x = int(lon / self.cell_size)
|
||||
center_y = int(lat / self.cell_size)
|
||||
|
||||
result = []
|
||||
for dx in range(-cells_to_check, cells_to_check + 1):
|
||||
for dy in range(-cells_to_check, cells_to_check + 1):
|
||||
result.extend(self.grid.get((center_x + dx, center_y + dy), []))
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Radial Calculation Pattern (Signal-Server style)
|
||||
|
||||
Instead of grid, calculate along radial spokes for faster coverage estimation.
|
||||
|
||||
### 2.1 Radial Engine
|
||||
|
||||
**File:** `backend/app/services/radial_coverage_service.py` (NEW)
|
||||
|
||||
```python
|
||||
"""
|
||||
Radial coverage calculation engine inspired by Signal-Server/SPLAT!
|
||||
|
||||
Instead of calculating every grid point independently:
|
||||
1. Cast rays from TX in all directions (0-360°)
|
||||
2. Sample terrain along each ray (profile)
|
||||
3. Apply propagation model to profile
|
||||
4. Interpolate between rays for final grid
|
||||
|
||||
This is 10-50x faster because:
|
||||
- Terrain profiles are linear (cache-friendly)
|
||||
- No building geometry per-point (use clutter model)
|
||||
- Embarrassingly parallel by azimuth
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import math
|
||||
|
||||
class RadialCoverageEngine:
|
||||
def __init__(self, terrain_service, propagation_model):
|
||||
self.terrain = terrain_service
|
||||
self.model = propagation_model
|
||||
|
||||
def calculate_coverage(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
radius_m: float,
|
||||
frequency_mhz: float,
|
||||
tx_power_dbm: float,
|
||||
num_radials: int = 360, # 1° resolution
|
||||
samples_per_radial: int = 100,
|
||||
num_threads: int = 8
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate coverage using radial ray-casting.
|
||||
|
||||
Returns dict with 'radials' (raw data) and 'grid' (interpolated).
|
||||
"""
|
||||
# Pre-load terrain tiles
|
||||
self._preload_terrain(tx_lat, tx_lon, radius_m)
|
||||
|
||||
# Calculate radials in parallel (by azimuth sectors)
|
||||
sector_size = num_radials // num_threads
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||
futures = []
|
||||
for i in range(num_threads):
|
||||
start_az = i * sector_size
|
||||
end_az = (i + 1) * sector_size if i < num_threads - 1 else num_radials
|
||||
|
||||
futures.append(executor.submit(
|
||||
self._calculate_sector,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
radius_m, frequency_mhz, tx_power_dbm,
|
||||
start_az, end_az, samples_per_radial
|
||||
))
|
||||
|
||||
# Collect results
|
||||
all_radials = []
|
||||
for f in futures:
|
||||
all_radials.extend(f.result())
|
||||
|
||||
return {
|
||||
'radials': all_radials,
|
||||
'center': (tx_lat, tx_lon),
|
||||
'radius': radius_m,
|
||||
'num_radials': num_radials
|
||||
}
|
||||
|
||||
def _calculate_sector(
|
||||
self,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
radius_m, frequency_mhz, tx_power_dbm,
|
||||
start_az, end_az, samples_per_radial
|
||||
) -> list:
|
||||
"""Calculate radials for one azimuth sector."""
|
||||
results = []
|
||||
|
||||
for az in range(start_az, end_az):
|
||||
radial = self._calculate_radial(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
radius_m, frequency_mhz, tx_power_dbm,
|
||||
az, samples_per_radial
|
||||
)
|
||||
results.append(radial)
|
||||
|
||||
return results
|
||||
|
||||
def _calculate_radial(
|
||||
self,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
radius_m, frequency_mhz, tx_power_dbm,
|
||||
azimuth_deg, num_samples
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate signal strength along one radial.
|
||||
|
||||
Uses terrain profile + Longley-Rice style calculation.
|
||||
"""
|
||||
az_rad = math.radians(azimuth_deg)
|
||||
cos_lat = math.cos(math.radians(tx_lat))
|
||||
|
||||
# Sample points along radial
|
||||
distances = np.linspace(100, radius_m, num_samples)
|
||||
|
||||
# Calculate lat/lon for each sample
|
||||
lat_offsets = (distances / 111000) * math.cos(az_rad)
|
||||
lon_offsets = (distances / (111000 * cos_lat)) * math.sin(az_rad)
|
||||
|
||||
lats = tx_lat + lat_offsets
|
||||
lons = tx_lon + lon_offsets
|
||||
|
||||
# Get terrain profile
|
||||
elevations = np.array([
|
||||
self.terrain.get_elevation_sync(lat, lon)
|
||||
for lat, lon in zip(lats, lons)
|
||||
])
|
||||
|
||||
tx_elevation = self.terrain.get_elevation_sync(tx_lat, tx_lon)
|
||||
|
||||
# Calculate path loss for each point
|
||||
rsrp_values = []
|
||||
los_flags = []
|
||||
|
||||
for i, (dist, elev) in enumerate(zip(distances, elevations)):
|
||||
# Simple LOS check using terrain profile up to this point
|
||||
profile = elevations[:i+1]
|
||||
has_los = self._check_los_profile(
|
||||
tx_elevation + tx_height,
|
||||
elev + 1.5, # RX height
|
||||
profile,
|
||||
distances[:i+1]
|
||||
)
|
||||
|
||||
# Path loss (using configured model)
|
||||
path_loss = self.model.calculate_path_loss(
|
||||
frequency_mhz, dist, tx_height, 1.5,
|
||||
has_los=has_los
|
||||
)
|
||||
|
||||
# Add diffraction loss if NLOS
|
||||
if not has_los:
|
||||
diff_loss = self._calculate_diffraction_loss(
|
||||
tx_elevation + tx_height,
|
||||
elev + 1.5,
|
||||
profile,
|
||||
distances[:i+1],
|
||||
frequency_mhz
|
||||
)
|
||||
path_loss += diff_loss
|
||||
|
||||
rsrp = tx_power_dbm - path_loss
|
||||
rsrp_values.append(rsrp)
|
||||
los_flags.append(has_los)
|
||||
|
||||
return {
|
||||
'azimuth': azimuth_deg,
|
||||
'distances': distances.tolist(),
|
||||
'lats': lats.tolist(),
|
||||
'lons': lons.tolist(),
|
||||
'rsrp': rsrp_values,
|
||||
'has_los': los_flags
|
||||
}
|
||||
|
||||
def _check_los_profile(self, tx_h, rx_h, profile, distances) -> bool:
|
||||
"""Check LOS using terrain profile (Fresnel zone clearance)."""
|
||||
if len(profile) < 2:
|
||||
return True
|
||||
|
||||
total_dist = distances[-1]
|
||||
|
||||
# Line from TX to RX
|
||||
for i in range(1, len(profile) - 1):
|
||||
d = distances[i]
|
||||
# Expected height on LOS line
|
||||
expected_h = tx_h + (rx_h - tx_h) * (d / total_dist)
|
||||
# Actual terrain height
|
||||
actual_h = profile[i]
|
||||
|
||||
if actual_h > expected_h - 0.6: # Small clearance margin
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _calculate_diffraction_loss(self, tx_h, rx_h, profile, distances, freq_mhz) -> float:
|
||||
"""Calculate diffraction loss using Deygout method."""
|
||||
# Find main obstacle
|
||||
max_v = -999
|
||||
max_idx = -1
|
||||
total_dist = distances[-1]
|
||||
wavelength = 300 / freq_mhz # meters
|
||||
|
||||
for i in range(1, len(profile) - 1):
|
||||
d1 = distances[i]
|
||||
d2 = total_dist - d1
|
||||
|
||||
# Height of LOS line at this point
|
||||
los_h = tx_h + (rx_h - tx_h) * (d1 / total_dist)
|
||||
|
||||
# Obstacle height above LOS
|
||||
h = profile[i] - los_h
|
||||
|
||||
if h > 0:
|
||||
# Fresnel parameter
|
||||
v = h * math.sqrt(2 * (d1 + d2) / (wavelength * d1 * d2))
|
||||
if v > max_v:
|
||||
max_v = v
|
||||
max_idx = i
|
||||
|
||||
if max_v < -0.78:
|
||||
return 0.0
|
||||
|
||||
# Knife-edge diffraction loss (ITU-R P.526)
|
||||
if max_v < 0:
|
||||
loss = 6.02 + 9.11 * max_v - 1.27 * max_v * max_v
|
||||
elif max_v < 2.4:
|
||||
loss = 6.02 + 9.11 * max_v + 1.65 * max_v * max_v
|
||||
else:
|
||||
loss = 12.953 + 20 * math.log10(max_v)
|
||||
|
||||
return max(0, loss)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Propagation Model Updates
|
||||
|
||||
### 3.1 Add Longley-Rice ITM Support
|
||||
|
||||
**File:** `backend/app/services/propagation_models/itm_model.py` (NEW)
|
||||
|
||||
```python
|
||||
"""
|
||||
Longley-Rice Irregular Terrain Model (ITM)
|
||||
|
||||
Best for: VHF/UHF terrain-based propagation (20 MHz - 20 GHz)
|
||||
Based on: itmlogic Python package
|
||||
|
||||
Key parameters:
|
||||
- Earth dielectric constant (eps): 4-81 (15 typical for ground)
|
||||
- Ground conductivity (sgm): 0.001-5.0 S/m
|
||||
- Atmospheric refractivity (ens): 250-400 N-units (301 typical)
|
||||
- Climate: 1=Equatorial, 2=Continental Subtropical, etc.
|
||||
"""
|
||||
|
||||
try:
|
||||
from itmlogic import itmlogic_p2p
|
||||
HAS_ITMLOGIC = True
|
||||
except ImportError:
|
||||
HAS_ITMLOGIC = False
|
||||
|
||||
from .base_model import BasePropagationModel, PropagationInput, PropagationResult
|
||||
|
||||
class LongleyRiceModel(BasePropagationModel):
|
||||
"""Longley-Rice ITM propagation model."""
|
||||
|
||||
name = "Longley-Rice-ITM"
|
||||
frequency_range = (20, 20000) # MHz
|
||||
distance_range = (1000, 2000000) # meters
|
||||
|
||||
# Default ITM parameters
|
||||
DEFAULT_PARAMS = {
|
||||
'eps': 15.0, # Earth dielectric constant
|
||||
'sgm': 0.005, # Ground conductivity (S/m)
|
||||
'ens': 301.0, # Atmospheric refractivity (N-units)
|
||||
'pol': 0, # Polarization: 0=horizontal, 1=vertical
|
||||
'mdvar': 12, # Mode of variability
|
||||
'klim': 5, # Climate: 5=Continental Temperate
|
||||
}
|
||||
|
||||
# Ground parameters by type
|
||||
GROUND_PARAMS = {
|
||||
'average': {'eps': 15.0, 'sgm': 0.005},
|
||||
'poor': {'eps': 4.0, 'sgm': 0.001},
|
||||
'good': {'eps': 25.0, 'sgm': 0.020},
|
||||
'fresh_water': {'eps': 81.0, 'sgm': 0.010},
|
||||
'sea_water': {'eps': 81.0, 'sgm': 5.0},
|
||||
'forest': {'eps': 12.0, 'sgm': 0.003},
|
||||
}
|
||||
|
||||
def __init__(self, ground_type: str = 'average', climate: int = 5):
|
||||
if not HAS_ITMLOGIC:
|
||||
raise ImportError("itmlogic package required: pip install itmlogic")
|
||||
|
||||
self.params = self.DEFAULT_PARAMS.copy()
|
||||
if ground_type in self.GROUND_PARAMS:
|
||||
self.params.update(self.GROUND_PARAMS[ground_type])
|
||||
self.params['klim'] = climate
|
||||
|
||||
def calculate(self, input: PropagationInput) -> PropagationResult:
|
||||
"""Calculate path loss using ITM point-to-point mode."""
|
||||
|
||||
# ITM requires terrain profile
|
||||
if not hasattr(input, 'terrain_profile') or input.terrain_profile is None:
|
||||
# Fallback to free-space if no terrain
|
||||
return self._free_space_fallback(input)
|
||||
|
||||
result = itmlogic_p2p(
|
||||
input.terrain_profile, # Elevation samples
|
||||
input.frequency_mhz,
|
||||
input.tx_height_m,
|
||||
input.rx_height_m,
|
||||
self.params['eps'],
|
||||
self.params['sgm'],
|
||||
self.params['ens'],
|
||||
self.params['pol'],
|
||||
self.params['mdvar'],
|
||||
self.params['klim']
|
||||
)
|
||||
|
||||
return PropagationResult(
|
||||
path_loss_db=result['dbloss'],
|
||||
model_name=self.name,
|
||||
details={
|
||||
'mode': result.get('propmode', 'unknown'),
|
||||
'variability': result.get('var', 0),
|
||||
}
|
||||
)
|
||||
|
||||
def _free_space_fallback(self, input: PropagationInput) -> PropagationResult:
|
||||
"""Free-space path loss when no terrain available."""
|
||||
fspl = 20 * np.log10(input.distance_m) + 20 * np.log10(input.frequency_mhz) - 27.55
|
||||
return PropagationResult(
|
||||
path_loss_db=fspl,
|
||||
model_name=f"{self.name} (FSPL fallback)",
|
||||
details={'mode': 'free_space'}
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 Add VHF/UHF Model Selection
|
||||
|
||||
**File:** `backend/app/services/propagation_models/model_selector.py`
|
||||
|
||||
```python
|
||||
"""
|
||||
Automatic propagation model selection based on frequency and environment.
|
||||
"""
|
||||
|
||||
def select_model_for_frequency(
|
||||
frequency_mhz: float,
|
||||
environment: str = 'urban',
|
||||
has_terrain: bool = True
|
||||
) -> BasePropagationModel:
|
||||
"""
|
||||
Select appropriate propagation model.
|
||||
|
||||
Frequency bands:
|
||||
- VHF: 30-300 MHz (tactical radios, FM broadcast)
|
||||
- UHF: 300-3000 MHz (tactical radios, TV, early cellular)
|
||||
- Cellular: 700-2600 MHz (LTE bands)
|
||||
- mmWave: 24-100 GHz (5G)
|
||||
|
||||
Decision tree:
|
||||
1. VHF/UHF with terrain → Longley-Rice ITM
|
||||
2. Urban cellular → COST-231 Hata
|
||||
3. Suburban/rural cellular → Okumura-Hata
|
||||
4. mmWave → 3GPP 38.901
|
||||
"""
|
||||
|
||||
# VHF (30-300 MHz)
|
||||
if 30 <= frequency_mhz <= 300:
|
||||
if has_terrain:
|
||||
return LongleyRiceModel(ground_type='average', climate=5)
|
||||
else:
|
||||
return FreeSpaceModel() # Fallback
|
||||
|
||||
# UHF (300-1000 MHz)
|
||||
elif 300 < frequency_mhz <= 1000:
|
||||
if has_terrain:
|
||||
return LongleyRiceModel(ground_type='average', climate=5)
|
||||
else:
|
||||
return OkumuraHataModel(environment=environment)
|
||||
|
||||
# Cellular (1000-2600 MHz)
|
||||
elif 1000 < frequency_mhz <= 2600:
|
||||
if environment == 'urban':
|
||||
return Cost231HataModel()
|
||||
else:
|
||||
return OkumuraHataModel(environment=environment)
|
||||
|
||||
# Higher frequencies
|
||||
else:
|
||||
return FreeSpaceModel() # Or implement 3GPP 38.901
|
||||
|
||||
|
||||
# Frequency band constants for UI
|
||||
FREQUENCY_BANDS = {
|
||||
'VHF_LOW': (30, 88, "VHF Low (30-88 MHz) - Military/Public Safety"),
|
||||
'VHF_HIGH': (136, 174, "VHF High (136-174 MHz) - Marine/Aviation"),
|
||||
'UHF_LOW': (400, 512, "UHF (400-512 MHz) - Public Safety/Tactical"),
|
||||
'UHF_TV': (470, 862, "UHF TV (470-862 MHz)"),
|
||||
'LTE_700': (700, 800, "LTE Band 28/20 (700-800 MHz)"),
|
||||
'LTE_900': (880, 960, "LTE Band 8 (900 MHz)"),
|
||||
'LTE_1800': (1710, 1880, "LTE Band 3 (1800 MHz)"),
|
||||
'LTE_2100': (1920, 2170, "LTE Band 1 (2100 MHz)"),
|
||||
'LTE_2600': (2500, 2690, "LTE Band 7 (2600 MHz)"),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Progress Bar Fix (WebSocket)
|
||||
|
||||
### 4.1 Proper Progress Streaming
|
||||
|
||||
The 5% bug persists because WebSocket messages aren't reaching frontend.
|
||||
|
||||
**Debug approach:**
|
||||
|
||||
```python
|
||||
# In coverage calculation, add explicit progress logging
|
||||
async def calculate_with_progress(self, ...):
|
||||
total_points = len(points)
|
||||
|
||||
for i, chunk_result in enumerate(chunk_results):
|
||||
progress = int((i + 1) / total_chunks * 100)
|
||||
|
||||
# Log to console AND send via WebSocket
|
||||
logger.info(f"[PROGRESS] {progress}% - chunk {i+1}/{total_chunks}")
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(progress, f"Calculating... {i+1}/{total_chunks}")
|
||||
await asyncio.sleep(0) # Yield to event loop
|
||||
```
|
||||
|
||||
**Frontend fix - check WebSocket subscription:**
|
||||
|
||||
```typescript
|
||||
// In App.tsx or CoverageStore
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('ws://localhost:8888/ws/coverage');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WS] Received:', data); // DEBUG
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setProgress(data.progress);
|
||||
setProgressStatus(data.status);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error('[WS] Error:', e);
|
||||
ws.onclose = () => console.log('[WS] Closed');
|
||||
|
||||
return () => ws.close();
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Testing & Validation
|
||||
|
||||
### 5.1 Performance Benchmarks
|
||||
|
||||
After refactoring, expected performance:
|
||||
|
||||
| Scenario | Before | After | Speedup |
|
||||
|----------|--------|-------|---------|
|
||||
| 5km Standard | 5s | 3s | 1.7x |
|
||||
| 5km Detailed | timeout | 25s | 12x |
|
||||
| 10km Standard | 30s | 10s | 3x |
|
||||
| 10km Detailed | timeout | 60s | 5x |
|
||||
|
||||
### 5.2 Test Commands
|
||||
|
||||
```powershell
|
||||
# Quick test
|
||||
cd D:\root\rfcp\installer
|
||||
.\test-detailed-quick.bat
|
||||
|
||||
# Check for [PROGRESS] logs in output
|
||||
# Check for [DP_TIMING] logs
|
||||
|
||||
# Verify shared memory cleanup
|
||||
# Check Task Manager for memory after calculation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Shared Memory for Buildings** (biggest impact) - Part 1.1
|
||||
2. **Increase Batch Size** - Part 1.2
|
||||
3. **Progress Bar Debug** - Part 4
|
||||
4. **Radial Engine** (optional, for preview mode) - Part 2
|
||||
5. **Longley-Rice ITM** (for VHF/UHF) - Part 3
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```
|
||||
# requirements.txt additions
|
||||
itmlogic>=0.1.0 # Longley-Rice ITM implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message
|
||||
|
||||
```
|
||||
feat: Iteration 3.3.0 - Performance Architecture Refactor
|
||||
|
||||
Performance:
|
||||
- Add shared memory for buildings (eliminate pickle overhead)
|
||||
- Increase batch size to 300-500 points (amortize IPC)
|
||||
- Add radial coverage engine (Signal-Server style)
|
||||
|
||||
Propagation Models:
|
||||
- Add Longley-Rice ITM for VHF/UHF (20 MHz - 20 GHz)
|
||||
- Add automatic model selection by frequency
|
||||
- Add frequency band constants for UI
|
||||
|
||||
Bug Fixes:
|
||||
- Debug and fix WebSocket progress (5% stuck bug)
|
||||
|
||||
Expected: 5km Detailed from timeout → ~25s (12x speedup)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude Code
|
||||
|
||||
This is a significant refactoring. Approach step by step:
|
||||
|
||||
1. First implement shared memory for buildings
|
||||
2. Test that alone - should see major speedup
|
||||
3. Then increase batch size
|
||||
4. Test again
|
||||
5. Then tackle progress bar
|
||||
6. Radial engine and ITM can be separate iterations if needed
|
||||
|
||||
The key insight: **99% of time is IPC overhead, not calculation**.
|
||||
Fixing pickle serialization is the #1 priority.
|
||||
|
||||
---
|
||||
|
||||
*"Fast per-point means nothing if IPC eats your lunch"* 🍽️
|
||||
@@ -0,0 +1,191 @@
|
||||
# RF Coverage Planning Software: Performance Optimization and Propagation Models
|
||||
|
||||
**The performance gap between fast per-point calculations (~1ms) and slow overall throughput (~258ms/point) is caused by pickle serialization overhead in Python multiprocessing**, which dominates actual compute time when processing small batches. The solution involves batching 1000+ points per IPC round-trip, using shared memory for terrain data, and leveraging GPU acceleration for workloads exceeding 10,000 points—achieving 10-50x speedups. Modern RF coverage tools like Signal-Server, SPLAT!, and Sionna RT demonstrate that combining radial segment parallelization, multi-resolution terrain tiling, and appropriate propagation model selection (Longley-Rice ITM for terrain-based VHF/UHF, COST-231 Hata for cellular) enables efficient large-area calculations while maintaining accuracy within 6-10 dB standard deviation.
|
||||
|
||||
---
|
||||
|
||||
## The multiprocessing bottleneck: why per-point speed deceives
|
||||
|
||||
The dramatic discrepancy between fast individual point calculations and slow aggregate throughput stems from a classic Python multiprocessing anti-pattern where **inter-process communication overhead dominates computation time**. When each worker processes a single point or small batch, the system spends more time serializing and deserializing data than performing actual RF calculations.
|
||||
|
||||
Python's multiprocessing uses pickle for IPC by default, requiring objects to be serialized twice per task (sending to worker and returning results). For RF calculations involving terrain data, DEM arrays, and GIS features, this serialization cost becomes catastrophic. Research shows that pickling a **40 MB dictionary four times per task can cause a 600% slowdown**. The situation worsens because spawning a subprocess takes approximately 50ms (50,000µs) compared to ~100µs for a thread—making process pool initialization per-request extremely expensive.
|
||||
|
||||
The solution architecture requires three fundamental changes. First, batch operations must amortize serialization costs by processing **1,000-10,000 points per IPC round-trip** rather than individual points. Second, shared memory (`multiprocessing.shared_memory` or `numpy.memmap`) should hold terrain data to eliminate pickle overhead entirely. Third, process pools must be pre-initialized at application startup rather than per-request:
|
||||
|
||||
```python
|
||||
# Anti-pattern: Single-point processing (slow)
|
||||
with Pool() as pool:
|
||||
results = pool.map(calculate_point, points) # Each point pickled separately
|
||||
|
||||
# Optimal pattern: Batch processing with shared memory
|
||||
from multiprocessing import shared_memory
|
||||
shm = shared_memory.SharedMemory(create=True, size=terrain_data.nbytes)
|
||||
chunk_size = 1000 # Process 1000 points per IPC round-trip
|
||||
batches = [points[i:i+chunk_size] for i in range(0, len(points), chunk_size)]
|
||||
```
|
||||
|
||||
The target metric is ensuring computation time exceeds serialization time by **10-100x**. For a 1ms per-point calculation, this means batching at least 100-1000 points to make serialization overhead negligible.
|
||||
|
||||
---
|
||||
|
||||
## Open-source RF tools reveal proven optimization architectures
|
||||
|
||||
**Signal-Server**, the C++14 multi-threaded engine that powered CloudRF from 2012-2016, demonstrates the foundational architecture for RF coverage calculations. Its primary improvement over the original SPLAT! was multi-threading through radial segment parallelization—splitting the circular coverage area so multiple threads process different azimuth ranges simultaneously. The implementation uses POSIX threads with configurable segment counts (must be even and greater than 4), processing up to 32 terrain tiles simultaneously with support for gzip/bzip2 compressed tiles for faster I/O.
|
||||
|
||||
Signal-Server supports 12 propagation models through a simple command-line parameter: ITM (Longley-Rice), line-of-sight, Hata, ECC33, SUI, COST-Hata, free-space, ITWOM, Ericsson, Plane Earth, Egli, and Soil models. The terrain tiling system uses SDF format converted from SRTM HGT files, supporting resolutions of 300/600/1200/3600 pixels per tile with automatic multi-tile loading based on calculation bounds.
|
||||
|
||||
**SPLAT!** (Signal Propagation, Loss, And Terrain), the foundational tool started in 1997, uses a radial ray-casting algorithm that projects rays from the transmitter in all azimuths (0-360°), samples terrain elevation along each path, and applies Longley-Rice ITM calculations to the terrain profile. Its Longley-Rice integration handles three prediction ranges (line-of-sight, diffraction, scatter) with terrain irregularity parameter Δh(d) computed from terrain samples. Key parameters include earth dielectric constant (5-80), ground conductivity (0.001-5.0 S/m), atmospheric refractivity (250-400 N-units), and climate zone selection.
|
||||
|
||||
**Sionna RT by NVIDIA** represents the state-of-the-art in GPU-accelerated RF simulation, using differentiable ray tracing built on TensorFlow, Mitsuba 3, and Dr.Jit. Its key innovation enables gradient computation through channel impulse responses with respect to material properties, antenna patterns, and transmitter/receiver positions—making it suitable for ML-integrated optimization. The path solver supports both Shooting and Bouncing Rays (SBR) and the Image Method, handling direct LOS paths, reflections, diffractions, and scattering patterns. Memory efficiency improvements in version 1.0 support scenes with 3D building models from OpenStreetMap, while configurable path loss thresholds and angular separation control enable scalable computation.
|
||||
|
||||
**CloudRF's SLEIPNIR engine** (replacing Signal-Server in 2019) achieves up to **10x faster** performance through multi-resolution modeling that seamlessly merges different resolution data sources, dual CPU/GPU engines (**78% speedup** with GPU for clutter calculations), and 1m LiDAR resolution support with global 10m land cover integration.
|
||||
|
||||
---
|
||||
|
||||
## VHF and UHF propagation models differ fundamentally from cellular bands
|
||||
|
||||
The **Longley-Rice Irregular Terrain Model (ITM)** serves as the most comprehensive model for terrain-based VHF/UHF propagation, predicting median attenuation over irregular terrain for frequencies from 20 MHz to 20 GHz across distances of 1-2000 km. The model handles five propagation mechanisms: free-space loss, terrain diffraction (multiple knife-edge), ground reflection, atmospheric refraction (4/3 Earth radius approximation), and tropospheric scatter beyond the horizon. Statistical variables include time, location, and situation variability ranging from 0.01 to 0.99, with typical accuracy of ±6-10 dB standard deviation for point-to-point mode.
|
||||
|
||||
Critical ITM parameters require careful selection based on environment:
|
||||
|
||||
| Ground Type | Permittivity | Conductivity (S/m) |
|
||||
|------------|--------------|-------------------|
|
||||
| Average Ground | 15 | 0.005 |
|
||||
| Poor Ground | 4 | 0.001 |
|
||||
| Good Ground | 25 | 0.020 |
|
||||
| Fresh Water | 81 | 0.010 |
|
||||
| Sea Water | 81 | 5.0 |
|
||||
|
||||
**ITU-R P.1546** provides empirical field-strength curves for 30 MHz to 4 GHz based on extensive Northern Hemisphere measurements, covering distances of 1-1000 km with time percentages of 1%, 10%, and 50%. The model uses reference frequencies of 100, 600, and 2000 MHz with interpolation for other frequencies, applying corrections for terrain clearance angle, receiving antenna height, clutter losses, and mixed land/sea paths.
|
||||
|
||||
For UHF and cellular bands, the **Okumura-Hata model** (150-1500 MHz, 1-20 km distance) and its **COST-231 extension** (1500-2000 MHz) provide rapid empirical calculations with 6-8 dB standard deviation in urban environments. The urban path loss formula is:
|
||||
|
||||
```
|
||||
L_urban = 69.55 + 26.16*log10(f) - 13.82*log10(h_b) - a(h_m)
|
||||
+ (44.9 - 6.55*log10(h_b))*log10(d)
|
||||
```
|
||||
|
||||
Where `a(h_m)` is the mobile antenna correction factor varying by city size and frequency. Suburban and rural corrections reduce urban loss by 2*(log10(f/28))² + 5.4 dB and 4.78*(log10(f))² - 18.33*log10(f) + 40.94 dB respectively.
|
||||
|
||||
The key propagation differences across frequency bands are dramatic: **VHF wavelengths (1-10m) enable strong diffraction around obstacles but poor building penetration**, while **UHF (0.1-1m wavelength) provides better building penetration but weaker terrain following**. Cellular frequencies (1800+ MHz) have the highest free-space loss baseline, weakest diffraction, and moderate building penetration. Vegetation penetration follows the opposite pattern—VHF penetrates foliage better than higher frequencies where specific attenuation increases significantly.
|
||||
|
||||
---
|
||||
|
||||
## Terrain diffraction models handle mountainous areas differently
|
||||
|
||||
The **single knife-edge diffraction model** (ITU-R P.526) calculates the Fresnel parameter v and corresponding loss:
|
||||
|
||||
```python
|
||||
v = h * sqrt(2 * (d1 + d2) / (wavelength * d1 * d2))
|
||||
# For v > -0.78:
|
||||
if v < 0: loss = 6.02 + 9.11*v - 1.27*v²
|
||||
elif v < 2.4: loss = 6.02 + 9.11*v + 1.65*v²
|
||||
else: loss = 12.953 + 20*log10(v)
|
||||
```
|
||||
|
||||
For multiple obstacles, the **Deygout method** finds the main obstacle (highest Fresnel parameter v between transmitter and receiver), calculates its diffraction loss, then recursively finds secondary obstacles on each side. It provides better accuracy for **widely spaced obstacles** (2-4 ridges) but tends to overestimate for closely spaced obstacles. The **Epstein-Peterson method** calculates diffraction loss sequentially from transmitter to receiver, providing better accuracy for **closely spaced obstacles** but underestimating for widely separated ones.
|
||||
|
||||
The **Bullington equivalent single edge** method replaces all obstacles with one equivalent knife edge, providing the simplest and fastest calculation but often underestimating loss (too optimistic)—useful only for initial estimates. Professional tools like CloudRF implement **Delta-Bullington** as the default for its balance of accuracy and speed, with configurable options including Huygens (basic), sequential multi-obstacle, and Deygout 94 with combining factor.
|
||||
|
||||
---
|
||||
|
||||
## GPU acceleration delivers 10-50x speedups for appropriate workloads
|
||||
|
||||
The RF calculations benefiting most from GPU acceleration are embarrassingly parallel operations: **ray tracing** (10-100x+ speedup with NVIDIA OptiX), **FFT operations** (cuFFT highly optimized), **viewshed/LOS calculations** (CloudRF reports **50x faster** than CPU), and **batch path loss calculations** for many points. Matrix operations in propagation models benefit from cuBLAS, while terrain correlation matrices and large array operations see significant acceleration.
|
||||
|
||||
**CuPy** provides a drop-in NumPy replacement for NVIDIA GPUs with 10-100x speedups for large arrays (>100,000 elements):
|
||||
|
||||
```python
|
||||
import cupy as cp
|
||||
terrain_gpu = cp.asarray(terrain_data)
|
||||
distances = cp.sqrt(cp.sum((points_gpu - tx_position)**2, axis=1))
|
||||
path_loss = 20 * cp.log10(distances) + 20 * cp.log10(frequency_mhz) - 27.55
|
||||
results = path_loss.get() # Transfer back to CPU
|
||||
```
|
||||
|
||||
**Numba CUDA** enables writing custom GPU kernels in Python for complex propagation models requiring control flow:
|
||||
|
||||
```python
|
||||
from numba import cuda
|
||||
import math
|
||||
|
||||
@cuda.jit
|
||||
def free_space_path_loss_kernel(distances, frequency, output):
|
||||
idx = cuda.grid(1)
|
||||
if idx < distances.shape[0]:
|
||||
output[idx] = 20 * math.log10(distances[idx]) + 20 * math.log10(frequency) - 27.55
|
||||
```
|
||||
|
||||
Minimum problem sizes for GPU benefit are: **10,000+ elements** for array operations, **1,024+ points** for FFT, **512x512+** for matrix multiply, and **5,000+ points** for path loss calculations. Memory transfer overhead (PCIe 3.0: ~8 GB/s practical) means the critical formula is `GPU_worthwhile = compute_time > (2 × transfer_time)`. For 100MB terrain data, transfer overhead is approximately 5-12ms.
|
||||
|
||||
**AMD ROCm/HIP** provides cross-platform compatibility through CuPy (`pip install cupy-rocm-5-0`), with PyTorch and TensorFlow also offering official ROCm builds. **Intel integrated graphics** support via PyOpenCL achieves 2-10x speedups over CPU (3-6x slower than discrete GPUs), suitable for edge deployments with moderate workloads (10,000-100,000 points).
|
||||
|
||||
---
|
||||
|
||||
## Environment modeling requires frequency-dependent clutter coefficients
|
||||
|
||||
**ITU-R P.1812-6** defines default clutter heights and losses by environment type: dense urban (20-25m height, 15-25 dB loss), urban (15-20m, 10-20 dB), suburban (9-12m, 5-15 dB), rural (0-4m, 0-5 dB), and forest (15-20m, 10-25 dB). The **3GPP TR 38.901** path loss models define specific scenarios: UMa (Urban Macro) with 25m base station height, UMi (Urban Micro Street Canyon) with 10m base station, RMa (Rural Macro), and InF (Indoor Factory) variants.
|
||||
|
||||
For vegetation, **ITU-R P.833-10** specifies excess attenuation using `A_ev = A_m * (1 - exp(-d*γ/A_m))` where specific attenuation γ varies by frequency: **0.06 dB/m at 200 MHz**, **0.20 dB/m at 1 GHz**, and **0.60 dB/m at 5 GHz** for in-leaf conditions. Seasonal variation reduces loss by approximately 20% out-of-leaf for deciduous forests, with **2 dB variation at 900 MHz increasing to 8.5 dB at 1800+ MHz**.
|
||||
|
||||
**Building entry loss** per ITU-R P.2109 distinguishes traditional buildings (median 10-16 dB at 100 MHz to 2 GHz) from thermally-efficient modern buildings with metallized glass and foil insulation (25-32 dB). Material-specific losses from 3GPP TR 38.901 show standard glass at **2.4 dB at 2 GHz**, concrete at **13 dB at 2 GHz increasing to 117 dB at 28 GHz**, and IRR/Low-E glass at **23.6 dB at 2 GHz**.
|
||||
|
||||
---
|
||||
|
||||
## Machine learning and hybrid approaches complement physics-based models
|
||||
|
||||
Current ML approaches for path loss prediction rank by accuracy: **XGBoost/Gradient Boosting** (RMSE: 2.1-3.4 dB, best for small-medium datasets), Neural Network Ensembles (2.5-4.0 dB), Random Forest (3.0-4.5 dB), and Deep Neural Networks (3.0-5.0 dB). Training data requirements scale predictably: <1,000 samples yield RMSE 6-10 dB, 10,000-100,000 samples achieve production-quality RMSE 2-4 dB.
|
||||
|
||||
**Hybrid physics+ML architectures** prove most effective. The ML Correction approach calculates `PL_total = PL_empirical(d, f, h_tx, h_rx) + ΔPL_ML(features)` where ΔPL_ML learns systematic biases. The LOS/NLOS Ensemble uses a classifier to weight separate LOS and NLOS regressors. Physics-Informed Neural Networks add penalty terms that enforce physical constraints like "path loss should increase with distance" and "FSPL provides a lower bound."
|
||||
|
||||
**Pre-computed propagation databases** store path loss values at 20-50 bytes per grid cell, enabling sub-millisecond lookups. For a 10km radius at 30m resolution (~349,000 cells), storage is approximately 7 MB compressed. Interpolation techniques range from fast bilinear (1-2 dB error) to kriging (higher accuracy with uncertainty estimates).
|
||||
|
||||
---
|
||||
|
||||
## Tile-based caching enables responsive coverage map delivery
|
||||
|
||||
The optimal caching architecture uses **XYZ (Slippy Map) tiles** with multi-tier storage: L1 in-memory Redis (sub-millisecond access, ~100GB capacity), L2 disk cache (SQLite/MBTiles format), and L3 cloud storage (S3 for permanent pre-computed tiles). Cache keys should incorporate parameter hashes for instant invalidation when transmitter settings change:
|
||||
|
||||
```python
|
||||
def get_tile_key(z: int, x: int, y: int, params_hash: str) -> str:
|
||||
return f"tile:coverage:{params_hash}:{z}:{x}:{y}"
|
||||
```
|
||||
|
||||
For dynamic coverage, TTL-based expiration (15 minutes to 24 hours) combined with Redis pub/sub channels (`map:update:region:*`) enables targeted geographic invalidation. The hybrid approach pre-computes base zoom levels (z=6-12) for commonly accessed areas while generating higher zoom levels (z>12) on-demand.
|
||||
|
||||
**Level of Detail (LOD) techniques** adapt computation intensity to distance: Tier 1 (0-500m) uses full 3D building geometry with 1m terrain resolution, Tier 2 (500m-2km) uses simplified buildings with 10m terrain, Tier 3 (2-10km) uses clutter heights only with 30m terrain, and Tier 4 (>10km) uses statistical clutter with 90m SRTM terrain. Adaptive grid generation provides higher resolution near the transmitter (10m) transitioning to coarser resolution (100m) at distance, reducing computation while maintaining visual quality where it matters.
|
||||
|
||||
---
|
||||
|
||||
## Recommended architecture for Python/FastAPI RF coverage backend
|
||||
|
||||
The optimal stack combines **FastAPI** (async API gateway with rate limiting), **Celery** (distributed task queue for heavy RF calculations), **Redis** (tile caching and job status), and **CuPy/Numba** (GPU acceleration). Terrain data should use **numpy.memmap** for memory-mapped access to large DEMs with **STRtree spatial indexing** for tile lookups via Shapely.
|
||||
|
||||
For the propagation engine, implement **Longley-Rice ITM** as the primary terrain model (using the `itmlogic` Python package), **COST-231 Hata** for quick urban estimates, and **Deygout diffraction** for multiple terrain obstacles. The model selection logic should consider frequency range (Hata for 150-1500 MHz, COST-231 for 1500-2000 MHz, ITM for terrain-specific), distance (empirical for <20km, ITM for longer paths), and accuracy requirements (ray tracing only for <5km urban scenarios).
|
||||
|
||||
```python
|
||||
class GPURFEngine:
|
||||
def __init__(self, max_points=1_000_000):
|
||||
# Pre-allocate GPU memory at startup
|
||||
self.d_buffer = cp.empty((max_points, 3), dtype=cp.float32)
|
||||
|
||||
async def calculate_coverage(self, points: np.ndarray) -> np.ndarray:
|
||||
if len(points) < 1000:
|
||||
return self._cpu_fallback(points) # Small workloads on CPU
|
||||
# GPU path for large workloads
|
||||
d_points = cp.asarray(points)
|
||||
# ... GPU computation
|
||||
return results.get()
|
||||
```
|
||||
|
||||
Celery configuration should use separate queues for fast (cached), compute (full calculation), and batch operations, with `worker_prefetch_multiplier=1` for heavy tasks and `task_acks_late=True` for reliability. Output formats should include PNG tiles with colormap lookup for web display and Cloud-Optimized GeoTIFF for professional GIS integration.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
Building efficient RF coverage planning software requires addressing the fundamental mismatch between fast per-point propagation calculations and the overhead of Python's multiprocessing model. **Batch processing (1000+ points per IPC round-trip), shared memory for terrain data, and GPU acceleration for workloads exceeding 10,000 points** provide the foundation for achieving throughput within an order of magnitude of commercial tools.
|
||||
|
||||
The propagation model selection should follow a tiered approach: Longley-Rice ITM for terrain-based VHF/UHF planning with available DEM data, Okumura-Hata/COST-231 for rapid urban cellular estimates, and Deygout diffraction for mountainous terrain with multiple obstacles. Environment modeling through ITU-R P.2108/P.2109/P.833 provides standardized clutter, building entry, and vegetation loss coefficients that maintain accuracy across diverse deployment scenarios.
|
||||
|
||||
The most impactful optimizations in order of implementation priority are: fixing the multiprocessing serialization bottleneck (immediate 100x throughput improvement), implementing tile-based caching with parameter-hash keys (sub-millisecond repeat queries), adding GPU acceleration for large coverage maps (10-50x for >10,000 points), and incorporating LOD techniques (3-10x computation reduction with minimal accuracy impact). This architecture enables a Python/FastAPI backend to compete with commercial tools while maintaining the flexibility for custom propagation models and ML integration.
|
||||
@@ -19,6 +19,8 @@ import SiteList from '@/components/panels/SiteList.tsx';
|
||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
|
||||
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
|
||||
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||
import type { SiteFormValues } from '@/components/modals/index.ts';
|
||||
@@ -394,8 +396,8 @@ export default function App() {
|
||||
const currentSettings = useCoverageStore.getState().settings;
|
||||
|
||||
// Validation
|
||||
if (currentSettings.radius > 100) {
|
||||
addToast('Radius too large (max 100km)', 'error');
|
||||
if (currentSettings.radius > 50) {
|
||||
addToast('Radius too large (max 50km)', 'error');
|
||||
return;
|
||||
}
|
||||
if (currentSettings.resolution < 50) {
|
||||
@@ -406,9 +408,17 @@ export default function App() {
|
||||
try {
|
||||
await calculateCoverageApi();
|
||||
|
||||
// Check result after calculation
|
||||
const result = useCoverageStore.getState().result;
|
||||
const error = useCoverageStore.getState().error;
|
||||
// After calculateCoverageApi returns, check if WS took over.
|
||||
// In WS mode, the function returns immediately and result arrives asynchronously.
|
||||
const state = useCoverageStore.getState();
|
||||
if (state.isCalculating && state.activeCalcId) {
|
||||
// WebSocket mode — toast will be shown from the WS onResult callback
|
||||
return;
|
||||
}
|
||||
|
||||
// HTTP mode — result is ready now
|
||||
const result = state.result;
|
||||
const error = state.error;
|
||||
|
||||
if (error) {
|
||||
let userMessage = 'Calculation failed';
|
||||
@@ -666,6 +676,7 @@ export default function App() {
|
||||
)}
|
||||
</MapView>
|
||||
<HeatmapLegend />
|
||||
<ResultsPanel />
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
@@ -706,14 +717,15 @@ export default function App() {
|
||||
<NumberInput
|
||||
label="Radius"
|
||||
value={settings.radius}
|
||||
onChange={(v) =>
|
||||
useCoverageStore.getState().updateSettings({ radius: v })
|
||||
}
|
||||
onChange={(v) => {
|
||||
const clamped = Math.min(v, 50);
|
||||
useCoverageStore.getState().updateSettings({ radius: clamped });
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
max={50}
|
||||
step={5}
|
||||
unit="km"
|
||||
hint="Calculation area around each site"
|
||||
hint="Calculation area around each site (max 50km)"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Resolution"
|
||||
@@ -1174,6 +1186,9 @@ export default function App() {
|
||||
modelsUsed={coverageResult?.modelsUsed}
|
||||
/>
|
||||
|
||||
{/* Session history */}
|
||||
<HistoryPanel />
|
||||
|
||||
{/* Export coverage data */}
|
||||
<ExportPanel />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function CoverageBoundary({
|
||||
points,
|
||||
visible,
|
||||
resolution,
|
||||
color = '#7c3aed', // purple-600 — visible against both map and orange gradient
|
||||
color = '#ffffff', // white — visible against red-to-blue gradient
|
||||
weight = 2,
|
||||
}: CoverageBoundaryProps) {
|
||||
const map = useMap();
|
||||
|
||||
@@ -13,12 +13,11 @@ import { useSitesStore } from '@/store/sites.ts';
|
||||
|
||||
const LEGEND_STEPS = [
|
||||
{ rsrp: -130, label: 'No Service' },
|
||||
{ rsrp: -110, label: 'Very Weak' },
|
||||
{ rsrp: -100, label: 'Weak' },
|
||||
{ rsrp: -90, label: 'Fair' },
|
||||
{ rsrp: -80, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Strong' },
|
||||
{ rsrp: -50, label: 'Excellent' },
|
||||
{ rsrp: -110, label: 'Weak' },
|
||||
{ rsrp: -100, label: 'Fair' },
|
||||
{ rsrp: -85, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Excellent' },
|
||||
{ rsrp: -50, label: 'Max' },
|
||||
];
|
||||
|
||||
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
||||
@@ -106,9 +105,9 @@ export default function HeatmapLegend() {
|
||||
|
||||
{/* Cutoff indicator + below-threshold (dimmed) */}
|
||||
{belowThreshold.length > 0 && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-dashed border-purple-400 dark:border-purple-500">
|
||||
<div className="mt-1.5 pt-1.5 border-t border-dashed border-gray-400 dark:border-gray-500">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-[9px] text-purple-500 dark:text-purple-400 font-medium">
|
||||
<span className="text-[9px] text-gray-500 dark:text-gray-400 font-medium">
|
||||
─ ─ Coverage boundary ({threshold} dBm)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||
import FrequencySelector from '@/components/panels/FrequencySelector.tsx';
|
||||
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
||||
import ModalBackdrop from './ModalBackdrop.tsx';
|
||||
|
||||
@@ -31,6 +30,7 @@ interface SiteConfigModalProps {
|
||||
const TEMPLATES = {
|
||||
limesdr: {
|
||||
label: 'LimeSDR',
|
||||
tooltip: 'SDR dev board — low power, short range testing (20 dBm, 2 dBi, 1800 MHz)',
|
||||
style: 'purple',
|
||||
name: 'LimeSDR Mini',
|
||||
power: 20,
|
||||
@@ -41,6 +41,7 @@ const TEMPLATES = {
|
||||
},
|
||||
lowBBU: {
|
||||
label: 'Low BBU',
|
||||
tooltip: 'Low-power baseband unit — suburban/campus coverage (40 dBm, 8 dBi, 1800 MHz)',
|
||||
style: 'green',
|
||||
name: 'Low Power BBU',
|
||||
power: 40,
|
||||
@@ -51,6 +52,7 @@ const TEMPLATES = {
|
||||
},
|
||||
highBBU: {
|
||||
label: 'High BBU',
|
||||
tooltip: 'High-power BBU — urban macro sector (43 dBm, 15 dBi, 65\u00B0 sector)',
|
||||
style: 'orange',
|
||||
name: 'High Power BBU',
|
||||
power: 43,
|
||||
@@ -63,6 +65,7 @@ const TEMPLATES = {
|
||||
},
|
||||
urbanMacro: {
|
||||
label: 'Urban Macro',
|
||||
tooltip: 'Standard urban macro site — rooftop/tower sector (43 dBm, 18 dBi, 65\u00B0 sector)',
|
||||
style: 'blue',
|
||||
name: 'Urban Macro Site',
|
||||
power: 43,
|
||||
@@ -75,6 +78,7 @@ const TEMPLATES = {
|
||||
},
|
||||
ruralTower: {
|
||||
label: 'Rural Tower',
|
||||
tooltip: 'Rural high tower — long range 800 MHz omni coverage (46 dBm, 8 dBi, 50m)',
|
||||
style: 'emerald',
|
||||
name: 'Rural Tower',
|
||||
power: 46,
|
||||
@@ -85,6 +89,7 @@ const TEMPLATES = {
|
||||
},
|
||||
smallCell: {
|
||||
label: 'Small Cell',
|
||||
tooltip: 'Urban small cell — street-level high capacity (30 dBm, 12 dBi, 2600 MHz)',
|
||||
style: 'cyan',
|
||||
name: 'Small Cell',
|
||||
power: 30,
|
||||
@@ -97,6 +102,7 @@ const TEMPLATES = {
|
||||
},
|
||||
indoorDAS: {
|
||||
label: 'Indoor DAS',
|
||||
tooltip: 'Indoor distributed antenna — in-building coverage (23 dBm, 2 dBi, 2100 MHz)',
|
||||
style: 'rose',
|
||||
name: 'Indoor DAS',
|
||||
power: 23,
|
||||
@@ -107,6 +113,7 @@ const TEMPLATES = {
|
||||
},
|
||||
uhfTactical: {
|
||||
label: 'UHF Tactical',
|
||||
tooltip: 'UHF tactical radio — man-portable field comms (25 dBm, 3 dBi, 450 MHz)',
|
||||
style: 'amber',
|
||||
name: 'UHF Tactical Radio',
|
||||
power: 25,
|
||||
@@ -117,6 +124,7 @@ const TEMPLATES = {
|
||||
},
|
||||
vhfRepeater: {
|
||||
label: 'VHF Repeater',
|
||||
tooltip: 'VHF repeater — long range voice/data relay (40 dBm, 6 dBi, 150 MHz)',
|
||||
style: 'teal',
|
||||
name: 'VHF Repeater',
|
||||
power: 40,
|
||||
@@ -203,8 +211,8 @@ export default function SiteConfigModal({
|
||||
if (form.power < 10 || form.power > 50) {
|
||||
newErrors.power = 'Power must be 10-50 dBm';
|
||||
}
|
||||
if (form.gain < 0 || form.gain > 25) {
|
||||
newErrors.gain = 'Gain must be 0-25 dBi';
|
||||
if (form.gain < 0 || form.gain > 30) {
|
||||
newErrors.gain = 'Gain must be 0-30 dBi';
|
||||
}
|
||||
if (form.frequency < 100 || form.frequency > 6000) {
|
||||
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
||||
@@ -360,20 +368,20 @@ export default function SiteConfigModal({
|
||||
label="Antenna Gain"
|
||||
value={form.gain}
|
||||
min={0}
|
||||
max={25}
|
||||
max={30}
|
||||
step={0.5}
|
||||
unit="dBi"
|
||||
hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
|
||||
hint={
|
||||
form.gain <= 8
|
||||
? `Omni-directional (${form.gain} dBi)`
|
||||
: form.gain <= 18
|
||||
? `Sector/Panel (${form.gain} dBi)`
|
||||
: `Parabolic/Dish (${form.gain} dBi)`
|
||||
}
|
||||
onChange={(v) => updateField('gain', v)}
|
||||
/>
|
||||
|
||||
{/* Frequency */}
|
||||
<FrequencySelector
|
||||
value={form.frequency}
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
/>
|
||||
|
||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector */}
|
||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */}
|
||||
<FrequencyBandPanel
|
||||
value={form.frequency}
|
||||
onChange={(v) => updateField('frequency', v)}
|
||||
@@ -485,6 +493,7 @@ export default function SiteConfigModal({
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
||||
title={t.tooltip}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||
>
|
||||
|
||||
@@ -19,8 +19,8 @@ function estimateAreaKm2(pointCount: number, resolutionM: number): number {
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ label: 'Excellent', threshold: -70, color: 'bg-green-500' },
|
||||
{ label: 'Good', threshold: -85, color: 'bg-lime-500' },
|
||||
{ label: 'Excellent', threshold: -70, color: 'bg-blue-500' },
|
||||
{ label: 'Good', threshold: -85, color: 'bg-green-500' },
|
||||
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
||||
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
||||
] as const;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* and propagation model info for each band.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
|
||||
import type { FrequencyBand } from '@/types/index.ts';
|
||||
|
||||
@@ -54,11 +55,25 @@ function getBandForFrequency(freq: number): string | null {
|
||||
|
||||
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
|
||||
const currentBand = getBandForFrequency(value);
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
|
||||
const handleCustomSubmit = () => {
|
||||
const parsed = parseInt(customInput, 10);
|
||||
if (parsed > 0 && parsed <= 100000) {
|
||||
onChange(parsed);
|
||||
setCustomInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||
Frequency Bands
|
||||
Operating Frequency
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-600 dark:text-dark-muted">
|
||||
{value} MHz
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
|
||||
@@ -139,6 +154,28 @@ export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPan
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom frequency input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Custom MHz..."
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
|
||||
className="flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-xs
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
min={1}
|
||||
max={100000}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSubmit}
|
||||
className="px-3 py-1.5 bg-gray-200 hover:bg-gray-300 dark:bg-dark-border dark:hover:bg-dark-muted dark:text-dark-text rounded-md text-xs text-gray-700 min-h-[28px]"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
137
frontend/src/components/panels/HistoryPanel.tsx
Normal file
137
frontend/src/components/panels/HistoryPanel.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||
|
||||
function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
||||
return (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1.5 text-[10px]">
|
||||
{/* Coverage breakdown with percentages */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center">
|
||||
<div>
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{entry.coverage.excellent.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Excellent</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-green-600 dark:text-green-400">
|
||||
{entry.coverage.good.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Good</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-yellow-600 dark:text-yellow-400">
|
||||
{entry.coverage.fair.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Fair</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-red-600 dark:text-red-400">
|
||||
{entry.coverage.weak.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-gray-400">Weak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSRP details */}
|
||||
<div className="flex justify-between text-gray-500 dark:text-dark-muted">
|
||||
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
|
||||
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HistoryPanel() {
|
||||
const entries = useCalcHistoryStore((s) => s.entries);
|
||||
const clearHistory = useCalcHistoryStore((s) => s.clearHistory);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expandedEntry, setExpandedEntry] = useState<string | null>(null);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-sm font-semibold text-gray-800 dark:text-dark-text"
|
||||
>
|
||||
<span className="text-[10px]">{expanded ? '\u25BC' : '\u25B6'}</span>
|
||||
Session History
|
||||
<span className="text-xs text-gray-400 dark:text-dark-muted font-normal ml-1">
|
||||
({entries.length})
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<button
|
||||
onClick={clearHistory}
|
||||
className="text-[10px] text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1.5 max-h-80 overflow-y-auto">
|
||||
{entries.map((entry) => {
|
||||
const isOpen = expandedEntry === entry.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => setExpandedEntry(isOpen ? null : entry.id)}
|
||||
className="w-full text-left text-xs border border-gray-100 dark:border-dark-border rounded p-2 space-y-1 hover:bg-gray-50 dark:hover:bg-dark-bg transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Row 1: timestamp + computation time */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-bold text-gray-800 dark:text-dark-text">
|
||||
{entry.computationTime.toFixed(1)}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2: badges */}
|
||||
<div className="flex gap-1.5 flex-wrap text-[10px]">
|
||||
<span className="px-1 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded">
|
||||
{entry.preset}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.totalPoints.toLocaleString()} pts
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.radius}km
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-dark-muted">
|
||||
{entry.resolution}m
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Coverage bar */}
|
||||
<div className="flex h-1.5 rounded-full overflow-hidden bg-gray-100 dark:bg-dark-border">
|
||||
{entry.coverage.excellent > 0 && (
|
||||
<div className="bg-blue-500" style={{ width: `${entry.coverage.excellent}%` }} />
|
||||
)}
|
||||
{entry.coverage.good > 0 && (
|
||||
<div className="bg-green-500" style={{ width: `${entry.coverage.good}%` }} />
|
||||
)}
|
||||
{entry.coverage.fair > 0 && (
|
||||
<div className="bg-yellow-500" style={{ width: `${entry.coverage.fair}%` }} />
|
||||
)}
|
||||
{entry.coverage.weak > 0 && (
|
||||
<div className="bg-red-500" style={{ width: `${entry.coverage.weak}%` }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable detail */}
|
||||
{isOpen && <EntryDetail entry={entry} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/panels/ResultsPanel.tsx
Normal file
163
frontend/src/components/panels/ResultsPanel.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import type { CoverageResult } from '@/types/index.ts';
|
||||
|
||||
function classifyCoverage(points: Array<{ rsrp: number }>) {
|
||||
const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
|
||||
for (const p of points) {
|
||||
if (p.rsrp > -70) counts.excellent++;
|
||||
else if (p.rsrp > -85) counts.good++;
|
||||
else if (p.rsrp > -100) counts.fair++;
|
||||
else counts.weak++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 10_000;
|
||||
|
||||
export default function ResultsPanel() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [show, setShow] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const prevResultRef = useRef<CoverageResult | null>(null);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
setTimeout(() => setShow(false), 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only trigger on NEW result (not initial mount with existing result)
|
||||
if (result && result !== prevResultRef.current && result.points.length > 0) {
|
||||
setShow(true);
|
||||
requestAnimationFrame(() => setVisible(true));
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(dismiss, AUTO_DISMISS_MS);
|
||||
}
|
||||
prevResultRef.current = result;
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [result, dismiss]);
|
||||
|
||||
if (!show || !result) return null;
|
||||
|
||||
const counts = classifyCoverage(result.points);
|
||||
const total = result.points.length;
|
||||
const preset = result.settings.preset ?? 'standard';
|
||||
const timeStr = result.calculationTime.toFixed(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`@keyframes rfcp-shrink { from { width: 100%; } to { width: 0%; } }`}</style>
|
||||
<div
|
||||
className={`absolute top-4 left-4 z-[1000] w-72
|
||||
bg-white/95 dark:bg-dark-surface/95 backdrop-blur-sm
|
||||
border border-gray-200 dark:border-dark-border rounded-lg shadow-lg
|
||||
transition-all duration-300 ease-out pointer-events-auto
|
||||
${visible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-8'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 pt-3 pb-1">
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-dark-text">
|
||||
Calculation Complete
|
||||
</h3>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-dark-text text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
{/* Time + points */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold text-gray-800 dark:text-dark-text">
|
||||
{timeStr}s
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
{total.toLocaleString()} points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Coverage breakdown bar */}
|
||||
<div className="flex h-2 rounded-full overflow-hidden">
|
||||
{counts.excellent > 0 && (
|
||||
<div className="bg-blue-500" style={{ width: `${(counts.excellent / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.good > 0 && (
|
||||
<div className="bg-green-500" style={{ width: `${(counts.good / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.fair > 0 && (
|
||||
<div className="bg-yellow-500" style={{ width: `${(counts.fair / total) * 100}%` }} />
|
||||
)}
|
||||
{counts.weak > 0 && (
|
||||
<div className="bg-red-500" style={{ width: `${(counts.weak / total) * 100}%` }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Coverage percentages */}
|
||||
<div className="grid grid-cols-4 gap-1 text-center text-[10px]">
|
||||
<div>
|
||||
<div className="font-semibold text-blue-600 dark:text-blue-400">
|
||||
{total > 0 ? ((counts.excellent / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Exc</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-green-600 dark:text-green-400">
|
||||
{total > 0 ? ((counts.good / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Good</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-yellow-600 dark:text-yellow-400">
|
||||
{total > 0 ? ((counts.fair / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Fair</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-red-600 dark:text-red-400">
|
||||
{total > 0 ? ((counts.weak / total) * 100).toFixed(0) : 0}%
|
||||
</div>
|
||||
<div className="text-gray-400">Weak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap gap-1.5 text-[10px] text-gray-500 dark:text-dark-muted">
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{preset}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.settings.radius}km
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.settings.resolution}m
|
||||
</span>
|
||||
{result.modelsUsed && result.modelsUsed.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-dark-border rounded">
|
||||
{result.modelsUsed.length} models
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-dismiss progress bar */}
|
||||
<div className="h-0.5 bg-gray-100 dark:bg-dark-border rounded-b-lg overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-400 dark:bg-blue-500"
|
||||
style={{
|
||||
animation: `rfcp-shrink ${AUTO_DISMISS_MS}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ interface PendingCalc {
|
||||
class WebSocketService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private _connected = false;
|
||||
private _pendingCalcs = new Map<string, PendingCalc>();
|
||||
private _connectionListeners = new Set<ConnectionCallback>();
|
||||
@@ -70,10 +71,20 @@ class WebSocketService {
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._setConnected(true);
|
||||
// Keepalive pings every 30s to prevent connection timeout during long calculations
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 30_000);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._setConnected(false);
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
// Fail all pending calculations — their callbacks reference the old socket
|
||||
this._failPendingCalcs('WebSocket disconnected');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
|
||||
};
|
||||
|
||||
@@ -121,8 +132,18 @@ class WebSocketService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Fail all pending calculations (e.g. on disconnect). */
|
||||
private _failPendingCalcs(reason: string): void {
|
||||
for (const [calcId, pending] of this._pendingCalcs) {
|
||||
try { pending.onError(reason); } catch { /* ignore */ }
|
||||
this._pendingCalcs.delete(calcId);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||
this._failPendingCalcs('WebSocket disconnected');
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this._setConnected(false);
|
||||
|
||||
38
frontend/src/store/calcHistory.ts
Normal file
38
frontend/src/store/calcHistory.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface CalculationEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
preset: string;
|
||||
radius: number;
|
||||
resolution: number;
|
||||
computationTime: number;
|
||||
totalPoints: number;
|
||||
coverage: { excellent: number; good: number; fair: number; weak: number };
|
||||
avgRsrp: number;
|
||||
rangeMin: number;
|
||||
rangeMax: number;
|
||||
}
|
||||
|
||||
interface CalcHistoryState {
|
||||
entries: CalculationEntry[];
|
||||
addEntry: (entry: CalculationEntry) => void;
|
||||
clearHistory: () => void;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
export const useCalcHistoryStore = create<CalcHistoryState>((set) => ({
|
||||
entries: [],
|
||||
|
||||
addEntry: (entry) =>
|
||||
set((state) => {
|
||||
const entries = [entry, ...state.entries];
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.length = MAX_ENTRIES;
|
||||
}
|
||||
return { entries };
|
||||
}),
|
||||
|
||||
clearHistory: () => set({ entries: [] }),
|
||||
}));
|
||||
@@ -3,6 +3,9 @@ import { api } from '@/services/api.ts';
|
||||
import { wsService } from '@/services/websocket.ts';
|
||||
import type { WSProgress } from '@/services/websocket.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||
|
||||
@@ -49,7 +52,7 @@ function buildApiSettings(settings: CoverageSettings) {
|
||||
return {
|
||||
radius: settings.radius * 1000, // km → meters
|
||||
resolution: settings.resolution,
|
||||
min_signal: settings.rsrpThreshold,
|
||||
min_signal: -130, // Send all useful points; frontend filters visually via rsrpThreshold
|
||||
preset: settings.preset,
|
||||
use_terrain: settings.use_terrain,
|
||||
use_buildings: settings.use_buildings,
|
||||
@@ -92,6 +95,44 @@ function responseToResult(response: CoverageResponse, settings: CoverageSettings
|
||||
};
|
||||
}
|
||||
|
||||
function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
||||
const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
|
||||
let minRsrp = Infinity;
|
||||
let maxRsrp = -Infinity;
|
||||
|
||||
for (const p of result.points) {
|
||||
if (p.rsrp > -70) counts.excellent++;
|
||||
else if (p.rsrp > -85) counts.good++;
|
||||
else if (p.rsrp > -100) counts.fair++;
|
||||
else counts.weak++;
|
||||
if (p.rsrp < minRsrp) minRsrp = p.rsrp;
|
||||
if (p.rsrp > maxRsrp) maxRsrp = p.rsrp;
|
||||
}
|
||||
|
||||
const total = result.points.length;
|
||||
const avgRsrp = result.stats?.avg_rsrp
|
||||
?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
preset: result.settings.preset ?? 'standard',
|
||||
radius: result.settings.radius,
|
||||
resolution: result.settings.resolution,
|
||||
computationTime: result.calculationTime,
|
||||
totalPoints: result.totalPoints,
|
||||
coverage: {
|
||||
excellent: total > 0 ? (counts.excellent / total) * 100 : 0,
|
||||
good: total > 0 ? (counts.good / total) * 100 : 0,
|
||||
fair: total > 0 ? (counts.fair / total) * 100 : 0,
|
||||
weak: total > 0 ? (counts.weak / total) * 100 : 0,
|
||||
},
|
||||
avgRsrp,
|
||||
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
|
||||
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
|
||||
};
|
||||
}
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
result: null,
|
||||
isCalculating: false,
|
||||
@@ -163,12 +204,36 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
apiSettings as unknown as Record<string, unknown>,
|
||||
// onResult
|
||||
(data) => {
|
||||
try {
|
||||
const result = responseToResult(data, settings);
|
||||
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
|
||||
// Show success toast for WS result
|
||||
const addToast = useToastStore.getState().addToast;
|
||||
if (result.points.length === 0) {
|
||||
addToast('No coverage points. Try increasing radius.', 'warning');
|
||||
} else {
|
||||
const timeStr = result.calculationTime.toFixed(1);
|
||||
const modelsStr = result.modelsUsed?.length
|
||||
? ` \u2022 ${result.modelsUsed.length} models`
|
||||
: '';
|
||||
addToast(
|
||||
`Calculated ${result.totalPoints.toLocaleString()} points in ${timeStr}s${modelsStr}`,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
// Push to session history
|
||||
if (result.points.length > 0) {
|
||||
useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Coverage] Failed to process result:', err);
|
||||
set({ isCalculating: false, error: 'Failed to process coverage result', progress: null, activeCalcId: null });
|
||||
}
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
set({ isCalculating: false, error, progress: null, activeCalcId: null });
|
||||
useToastStore.getState().addToast(`Calculation failed: ${error}`, 'error');
|
||||
},
|
||||
// onProgress
|
||||
(progress) => {
|
||||
@@ -191,6 +256,10 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
|
||||
const result = responseToResult(response, settings);
|
||||
set({ result, isCalculating: false, error: null });
|
||||
// Push to session history
|
||||
if (result.points.length > 0) {
|
||||
useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
set({ isCalculating: false });
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* RSRP → color mapping with smooth gradient interpolation.
|
||||
*
|
||||
* Purple → Orange palette:
|
||||
* -130 dBm = deep purple (no service)
|
||||
* -90 dBm = peach (fair)
|
||||
* -50 dBm = bright orange (excellent)
|
||||
* CloudRF-style Red → Blue palette:
|
||||
* -130 dBm = dark red (no service)
|
||||
* -100 dBm = yellow (fair)
|
||||
* -70 dBm = green (good)
|
||||
* -50 dBm = deep blue (excellent)
|
||||
*
|
||||
* All functions are pure and allocation-free on the hot path
|
||||
* (pre-built lookup table for fast per-pixel color resolution).
|
||||
@@ -18,14 +19,13 @@ interface GradientStop {
|
||||
}
|
||||
|
||||
const GRADIENT_STOPS: GradientStop[] = [
|
||||
{ value: 0.0, r: 26, g: 0, b: 51 }, // #1a0033 — deep purple (no service)
|
||||
{ value: 0.15, r: 74, g: 20, b: 140 }, // #4a148c — dark purple
|
||||
{ value: 0.30, r: 123, g: 31, b: 162 }, // #7b1fa2 — purple (very weak)
|
||||
{ value: 0.45, r: 171, g: 71, b: 188 }, // #ab47bc — light purple (weak)
|
||||
{ value: 0.60, r: 255, g: 138, b: 101 }, // #ff8a65 — peach (fair)
|
||||
{ value: 0.75, r: 255, g: 111, b: 0 }, // #ff6f00 — dark orange (good)
|
||||
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange (strong)
|
||||
{ value: 1.0, r: 255, g: 183, b: 77 }, // #ffb74d — bright orange (excellent)
|
||||
{ value: 0.0, r: 127, g: 0, b: 0 }, // #7f0000 — dark red (no service)
|
||||
{ value: 0.15, r: 239, g: 68, b: 68 }, // #EF4444 — red (very weak)
|
||||
{ value: 0.30, r: 249, g: 115, b: 22 }, // #F97316 — orange (weak)
|
||||
{ value: 0.50, r: 234, g: 179, b: 8 }, // #EAB308 — yellow (fair)
|
||||
{ value: 0.70, r: 34, g: 197, b: 94 }, // #22C55E — green (good)
|
||||
{ value: 0.85, r: 59, g: 130, b: 246 }, // #3B82F6 — blue (strong)
|
||||
{ value: 1.0, r: 37, g: 99, b: 235 }, // #2563EB — deep blue (excellent)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user