diff --git a/RFCP-Iteration-3.4.0-Large-Radius-Support.md b/RFCP-Iteration-3.4.0-Large-Radius-Support.md new file mode 100644 index 0000000..881974d --- /dev/null +++ b/RFCP-Iteration-3.4.0-Large-Radius-Support.md @@ -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 ; +} +``` + +--- + +## 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"* 🗺️ diff --git a/RFCP-Session-Summary-2025-02-01.md b/RFCP-Session-Summary-2025-02-01.md deleted file mode 100644 index 3a3b16c..0000000 --- a/RFCP-Session-Summary-2025-02-01.md +++ /dev/null @@ -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."* diff --git a/backend/app/api/websocket.py b/backend/app/api/websocket.py index ceb0f9e..34ec9fa 100644 --- a/backend/app/api/websocket.py +++ b/backend/app/api/websocket.py @@ -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) diff --git a/backend/app/services/coverage_service.py b/backend/app/services/coverage_service.py index b93ea75..078ac7c 100644 --- a/backend/app/services/coverage_service.py +++ b/backend/app/services/coverage_service.py @@ -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 — diff --git a/backend/app/services/parallel_coverage_service.py b/backend/app/services/parallel_coverage_service.py index 97b8796..9d29cab 100644 --- a/backend/app/services/parallel_coverage_service.py +++ b/backend/app/services/parallel_coverage_service.py @@ -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() diff --git a/RFCP-Iteration-3.1.0-LOD-Optimization.md b/docs/devlog/installer/RFCP-Iteration-3.1.0-LOD-Optimization.md similarity index 100% rename from RFCP-Iteration-3.1.0-LOD-Optimization.md rename to docs/devlog/installer/RFCP-Iteration-3.1.0-LOD-Optimization.md diff --git a/RFCP-Iteration-3.2.0-Comprehensive-Fixes.md b/docs/devlog/installer/RFCP-Iteration-3.2.0-Comprehensive-Fixes.md similarity index 100% rename from RFCP-Iteration-3.2.0-Comprehensive-Fixes.md rename to docs/devlog/installer/RFCP-Iteration-3.2.0-Comprehensive-Fixes.md diff --git a/RFCP-Iteration-3.2.2-Diagnostic.md b/docs/devlog/installer/RFCP-Iteration-3.2.2-Diagnostic.md similarity index 100% rename from RFCP-Iteration-3.2.2-Diagnostic.md rename to docs/devlog/installer/RFCP-Iteration-3.2.2-Diagnostic.md diff --git a/RFCP-Iteration-3.3.0-Architecture-Refactor.md b/docs/devlog/installer/RFCP-Iteration-3.3.0-Architecture-Refactor.md similarity index 100% rename from RFCP-Iteration-3.3.0-Architecture-Refactor.md rename to docs/devlog/installer/RFCP-Iteration-3.3.0-Architecture-Refactor.md diff --git a/RFCP-Phase-2.4-GPU-Elevation.md b/docs/devlog/installer/RFCP-Phase-2.4-GPU-Elevation.md similarity index 100% rename from RFCP-Phase-2.4-GPU-Elevation.md rename to docs/devlog/installer/RFCP-Phase-2.4-GPU-Elevation.md diff --git a/RFCP-Phase-2.4.1-Critical-Fixes.md b/docs/devlog/installer/RFCP-Phase-2.4.1-Critical-Fixes.md similarity index 100% rename from RFCP-Phase-2.4.1-Critical-Fixes.md rename to docs/devlog/installer/RFCP-Phase-2.4.1-Critical-Fixes.md diff --git a/RFCP-Phase-2.4.2-Final-Fixes.md b/docs/devlog/installer/RFCP-Phase-2.4.2-Final-Fixes.md similarity index 100% rename from RFCP-Phase-2.4.2-Final-Fixes.md rename to docs/devlog/installer/RFCP-Phase-2.4.2-Final-Fixes.md diff --git a/RFCP-Phase-2.5.0-NumPy-Vectorization.md b/docs/devlog/installer/RFCP-Phase-2.5.0-NumPy-Vectorization.md similarity index 100% rename from RFCP-Phase-2.5.0-NumPy-Vectorization.md rename to docs/devlog/installer/RFCP-Phase-2.5.0-NumPy-Vectorization.md diff --git a/RFCP-Phase-2.5.1-Performance-AppClose.md b/docs/devlog/installer/RFCP-Phase-2.5.1-Performance-AppClose.md similarity index 100% rename from RFCP-Phase-2.5.1-Performance-AppClose.md rename to docs/devlog/installer/RFCP-Phase-2.5.1-Performance-AppClose.md diff --git a/RFCP-Phase-3.0-Architecture-Refactor.md b/docs/devlog/installer/RFCP-Phase-3.0-Architecture-Refactor.md similarity index 100% rename from RFCP-Phase-3.0-Architecture-Refactor.md rename to docs/devlog/installer/RFCP-Phase-3.0-Architecture-Refactor.md diff --git a/compass_artifact_wf-6a6a41ed-a3b3-461d-9341-81ce0ff4260d_text_markdown.md b/docs/devlog/installer/compass_artifact_wf-6a6a41ed-a3b3-461d-9341-81ce0ff4260d_text_markdown.md similarity index 100% rename from compass_artifact_wf-6a6a41ed-a3b3-461d-9341-81ce0ff4260d_text_markdown.md rename to docs/devlog/installer/compass_artifact_wf-6a6a41ed-a3b3-461d-9341-81ce0ff4260d_text_markdown.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4363a62..b474aa5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { )} + {/* Side panel */} @@ -706,14 +717,15 @@ export default function App() { - 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)" /> + {/* Session history */} + + {/* Export coverage data */} diff --git a/frontend/src/components/map/CoverageBoundary.tsx b/frontend/src/components/map/CoverageBoundary.tsx index 14aa3a8..db7b8d4 100644 --- a/frontend/src/components/map/CoverageBoundary.tsx +++ b/frontend/src/components/map/CoverageBoundary.tsx @@ -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(); diff --git a/frontend/src/components/map/HeatmapLegend.tsx b/frontend/src/components/map/HeatmapLegend.tsx index 3c99acd..6c96f47 100644 --- a/frontend/src/components/map/HeatmapLegend.tsx +++ b/frontend/src/components/map/HeatmapLegend.tsx @@ -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 && ( -
+
- + ─ ─ Coverage boundary ({threshold} dBm)
diff --git a/frontend/src/components/modals/SiteConfigModal.tsx b/frontend/src/components/modals/SiteConfigModal.tsx index 363d54a..a571a08 100644 --- a/frontend/src/components/modals/SiteConfigModal.tsx +++ b/frontend/src/components/modals/SiteConfigModal.tsx @@ -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 */} - updateField('frequency', v)} - /> - - {/* Band panel — UHF/VHF/LTE/5G grouped selector */} + {/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */} 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}`} > diff --git a/frontend/src/components/panels/CoverageStats.tsx b/frontend/src/components/panels/CoverageStats.tsx index d3c83c1..845463a 100644 --- a/frontend/src/components/panels/CoverageStats.tsx +++ b/frontend/src/components/panels/CoverageStats.tsx @@ -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; diff --git a/frontend/src/components/panels/FrequencyBandPanel.tsx b/frontend/src/components/panels/FrequencyBandPanel.tsx index 2b23875..aa8af40 100644 --- a/frontend/src/components/panels/FrequencyBandPanel.tsx +++ b/frontend/src/components/panels/FrequencyBandPanel.tsx @@ -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 (
-
- Frequency Bands +
+
+ Operating Frequency +
+
+ {value} MHz +
{(Object.keys(FREQUENCY_GROUPS) as Array).map((bandType) => { @@ -139,6 +154,28 @@ export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPan
); })} + + {/* Custom frequency input */} +
+ 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} + /> + +
); } diff --git a/frontend/src/components/panels/HistoryPanel.tsx b/frontend/src/components/panels/HistoryPanel.tsx new file mode 100644 index 0000000..95f8afb --- /dev/null +++ b/frontend/src/components/panels/HistoryPanel.tsx @@ -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 ( +
+ {/* Coverage breakdown with percentages */} +
+
+
+ {entry.coverage.excellent.toFixed(0)}% +
+
Excellent
+
+
+
+ {entry.coverage.good.toFixed(0)}% +
+
Good
+
+
+
+ {entry.coverage.fair.toFixed(0)}% +
+
Fair
+
+
+
+ {entry.coverage.weak.toFixed(0)}% +
+
Weak
+
+
+ + {/* RSRP details */} +
+ Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm + Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm +
+
+ ); +} + +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(null); + + if (entries.length === 0) return null; + + return ( +
+
+ + {expanded && ( + + )} +
+ + {expanded && ( +
+ {entries.map((entry) => { + const isOpen = expandedEntry === entry.id; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/panels/ResultsPanel.tsx b/frontend/src/components/panels/ResultsPanel.tsx new file mode 100644 index 0000000..239eca2 --- /dev/null +++ b/frontend/src/components/panels/ResultsPanel.tsx @@ -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 | undefined>(undefined); + const prevResultRef = useRef(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 ( + <> + +
+ {/* Header */} +
+

+ Calculation Complete +

+ +
+ + {/* Body */} +
+ {/* Time + points */} +
+ + {timeStr}s + + + {total.toLocaleString()} points + +
+ + {/* Coverage breakdown bar */} +
+ {counts.excellent > 0 && ( +
+ )} + {counts.good > 0 && ( +
+ )} + {counts.fair > 0 && ( +
+ )} + {counts.weak > 0 && ( +
+ )} +
+ + {/* Coverage percentages */} +
+
+
+ {total > 0 ? ((counts.excellent / total) * 100).toFixed(0) : 0}% +
+
Exc
+
+
+
+ {total > 0 ? ((counts.good / total) * 100).toFixed(0) : 0}% +
+
Good
+
+
+
+ {total > 0 ? ((counts.fair / total) * 100).toFixed(0) : 0}% +
+
Fair
+
+
+
+ {total > 0 ? ((counts.weak / total) * 100).toFixed(0) : 0}% +
+
Weak
+
+
+ + {/* Metadata */} +
+ + {preset} + + + {result.settings.radius}km + + + {result.settings.resolution}m + + {result.modelsUsed && result.modelsUsed.length > 0 && ( + + {result.modelsUsed.length} models + + )} +
+
+ + {/* Auto-dismiss progress bar */} +
+
+
+
+ + ); +} diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts index 0ec95a6..b085d75 100644 --- a/frontend/src/services/websocket.ts +++ b/frontend/src/services/websocket.ts @@ -33,6 +33,7 @@ interface PendingCalc { class WebSocketService { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | undefined; + private pingTimer: ReturnType | undefined; private _connected = false; private _pendingCalcs = new Map(); private _connectionListeners = new Set(); @@ -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); diff --git a/frontend/src/store/calcHistory.ts b/frontend/src/store/calcHistory.ts new file mode 100644 index 0000000..f5a271f --- /dev/null +++ b/frontend/src/store/calcHistory.ts @@ -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((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: [] }), +})); diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts index 7746283..d55c547 100644 --- a/frontend/src/store/coverage.ts +++ b/frontend/src/store/coverage.ts @@ -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((set, get) => ({ result: null, isCalculating: false, @@ -163,12 +204,36 @@ export const useCoverageStore = create((set, get) => ({ apiSettings as unknown as Record, // onResult (data) => { - const result = responseToResult(data, settings); - set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null }); + 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((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 }); diff --git a/frontend/src/utils/colorGradient.ts b/frontend/src/utils/colorGradient.ts index 7d6341a..ef27626 100644 --- a/frontend/src/utils/colorGradient.ts +++ b/frontend/src/utils/colorGradient.ts @@ -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) ]; /** diff --git a/installer/detailed-result.json b/installer/detailed-result.json index 9814f22..e8ff401 100644 --- a/installer/detailed-result.json +++ b/installer/detailed-result.json @@ -1 +1 @@ -{"detail":"Calculation timeout (5 min). Cleaned up 4 workers."} \ No newline at end of file +{"points":[{"lat":50.4427928,"lon":30.4535025,"rsrp":-114.57095757980863,"distance":4776.327858778684,"has_los":false,"terrain_loss":9.453017124933375,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4577471,"rsrp":-113.73544554762684,"distance":4480.317074443244,"has_los":false,"terrain_loss":9.5962410903238,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4619916,"rsrp":-113.75686460120053,"distance":4184.961133731838,"has_los":false,"terrain_loss":10.660926016719886,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4662361,"rsrp":-113.3591330102574,"distance":3890.4006156776886,"has_los":false,"terrain_loss":11.379720332657806,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4704806,"rsrp":-112.1779447063071,"distance":3596.8309487883807,"has_los":false,"terrain_loss":11.398794291595998,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4747251,"rsrp":-111.06508716890035,"distance":3304.5162231564223,"has_los":false,"terrain_loss":11.582639009992775,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4789697,"rsrp":-108.52928921396732,"distance":3013.814791497514,"has_los":false,"terrain_loss":10.45552982731549,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4832142,"rsrp":-111.10568226437505,"distance":2725.2588519891615,"has_los":false,"terrain_loss":14.571559091443957,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4874587,"rsrp":-110.89819632735274,"distance":2439.6005231322424,"has_los":false,"terrain_loss":16.058002693564312,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4917032,"rsrp":-110.689718193417,"distance":2157.990803648135,"has_los":false,"terrain_loss":17.725921918894255,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.5468819,"rsrp":-102.78177239655489,"distance":2065.3031851927776,"has_los":false,"terrain_loss":10.489565288462506,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.5511265,"rsrp":-110.26855203832413,"distance":2345.2105364801055,"has_los":false,"terrain_loss":16.032002570278294,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4535025,"rsrp":-112.92387778532812,"distance":4735.047144677672,"has_los":false,"terrain_loss":7.938728792975363,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4577471,"rsrp":-113.68081520629791,"distance":4436.300397751301,"has_los":false,"terrain_loss":9.692648034803243,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4619916,"rsrp":-112.79807445491802,"distance":4137.82140564606,"has_los":false,"terrain_loss":9.875431137024561,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4662361,"rsrp":-112.8337987225987,"distance":3839.6639292985146,"has_los":false,"terrain_loss":11.055206870054993,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4704806,"rsrp":-112.74692370104707,"distance":3541.909166204692,"has_los":false,"terrain_loss":12.203167158096548,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4747251,"rsrp":-111.77226907906913,"distance":3244.6679872148493,"has_los":false,"terrain_loss":12.56942259457833,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4789697,"rsrp":-110.87456545335785,"distance":2948.088765844669,"has_los":false,"terrain_loss":13.13811943341141,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4832142,"rsrp":-110.7816198622974,"distance":2652.409877823923,"has_los":false,"terrain_loss":14.661991877649589,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4874587,"rsrp":-110.36353142174855,"distance":2357.960415392841,"has_los":false,"terrain_loss":16.04403892497462,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4917032,"rsrp":-110.87277531703376,"distance":2065.2662922392547,"has_los":false,"terrain_loss":18.58084148212976,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.5511265,"rsrp":-118.1020000972668,"distance":2260.1692107739464,"has_los":false,"terrain_loss":24.430488393553528,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4535025,"rsrp":-113.11992733277572,"distance":4712.607289554922,"has_los":false,"terrain_loss":8.207449216573822,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4577471,"rsrp":-112.83275087840336,"distance":4412.359200038484,"has_los":false,"terrain_loss":8.927365146605734,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4619916,"rsrp":-112.09714938976785,"distance":4112.160580829812,"has_los":false,"terrain_loss":9.269672231226231,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4662361,"rsrp":-112.83007499387445,"distance":3812.014375144207,"has_los":false,"terrain_loss":11.162042841122865,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4704806,"rsrp":-111.8848696388209,"distance":3511.934022621575,"has_los":false,"terrain_loss":11.471130537497485,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4747251,"rsrp":-111.50050276421196,"distance":3211.93798173643,"has_los":false,"terrain_loss":12.452755296771086,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4789697,"rsrp":-111.30291051622041,"distance":2912.0452470704267,"has_los":false,"terrain_loss":13.754651072976774,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4832142,"rsrp":-110.67437957943807,"distance":2612.3079619184136,"has_los":false,"terrain_loss":14.787808487476044,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4874587,"rsrp":-109.64328535861614,"distance":2312.7767529815947,"has_los":false,"terrain_loss":15.619780163447215,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4917032,"rsrp":-108.12941174673985,"distance":2013.5435901638145,"has_los":false,"terrain_loss":16.225480437967644,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0}],"count":33,"settings":{"radius":5000.0,"resolution":300.0,"min_signal":-120.0,"environment":"urban","use_terrain":true,"use_buildings":true,"use_materials":true,"use_dominant_path":true,"use_street_canyon":false,"use_reflections":false,"use_water_reflection":false,"use_vegetation":true,"season":"summer","rain_rate":0.0,"indoor_loss_type":"none","use_atmospheric":false,"temperature_c":15.0,"humidity_percent":50.0,"preset":"detailed"},"stats":{"min_rsrp":-118.1020000972668,"max_rsrp":-102.78177239655489,"avg_rsrp":-111.65770170751283,"los_percentage":0.0,"points_with_buildings":0,"points_with_terrain_loss":33,"points_with_reflection_gain":0,"points_with_vegetation_loss":0,"points_with_rain_loss":0,"points_with_indoor_loss":0,"points_with_atmospheric_loss":0},"computation_time":67.47,"models_used":["COST-231-Hata","terrain_los","buildings","materials","dominant_path","vegetation"]} \ No newline at end of file