@mytec: iter3.4.0 start
This commit is contained in:
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 time
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import WebSocket, WebSocketDisconnect
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
@@ -51,7 +50,7 @@ class ConnectionManager:
|
|||||||
"data": result,
|
"data": result,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
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):
|
async def send_error(self, ws: WebSocket, calc_id: str, error: str):
|
||||||
try:
|
try:
|
||||||
@@ -61,7 +60,7 @@ class ConnectionManager:
|
|||||||
"message": error,
|
"message": error,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[WS] send_error failed: {e}")
|
logger.warning(f"[WS] send_error failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
ws_manager = ConnectionManager()
|
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.
|
# Shared progress state — written by worker threads, polled by event loop.
|
||||||
# Python GIL makes dict value assignment atomic for simple types.
|
# 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
|
_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):
|
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["phase"] = phase
|
||||||
_progress["pct"] = pct
|
_progress["pct"] = pct
|
||||||
_progress["seq"] += 1
|
_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:
|
try:
|
||||||
sites_data = data.get("sites", [])
|
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:
|
if primary_model.name not in models_used:
|
||||||
models_used.insert(0, primary_model.name)
|
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():
|
async def progress_poller():
|
||||||
last_sent_seq = 0
|
last_sent_seq = 0
|
||||||
last_sent_pct = 0.0
|
last_sent_pct = 0.0
|
||||||
|
last_sent_phase = "Initializing"
|
||||||
while not _done:
|
while not _done:
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.5)
|
||||||
seq = _progress["seq"]
|
seq = _progress["seq"]
|
||||||
pct = _progress["pct"]
|
pct = _progress["pct"]
|
||||||
phase = _progress["phase"]
|
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)
|
await ws_manager.send_progress(ws, calc_id, phase, pct)
|
||||||
last_sent_seq = seq
|
last_sent_seq = seq
|
||||||
last_sent_pct = pct
|
last_sent_pct = pct
|
||||||
|
last_sent_phase = phase
|
||||||
|
|
||||||
poller_task = asyncio.create_task(progress_poller())
|
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(
|
points = await asyncio.wait_for(
|
||||||
coverage_service.calculate_multi_site_coverage(
|
coverage_service.calculate_multi_site_coverage(
|
||||||
sites, settings, cancel_token,
|
sites, settings, cancel_token,
|
||||||
|
progress_fn=sync_progress_fn,
|
||||||
),
|
),
|
||||||
timeout=300.0,
|
timeout=300.0,
|
||||||
)
|
)
|
||||||
@@ -170,7 +194,6 @@ async def _run_calculation(ws: WebSocket, calc_id: str, data: dict):
|
|||||||
# Stop poller and send final progress
|
# Stop poller and send final progress
|
||||||
_done = True
|
_done = True
|
||||||
await poller_task
|
await poller_task
|
||||||
await ws_manager.send_progress(ws, calc_id, "Finalizing", 0.98)
|
|
||||||
|
|
||||||
computation_time = time.time() - start_time
|
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,
|
"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)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[WS] Calculation error: {e}", exc_info=True)
|
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)
|
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)
|
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
|
# Build spatial index for buildings
|
||||||
spatial_idx: Optional[SpatialIndex] = None
|
spatial_idx: Optional[SpatialIndex] = None
|
||||||
@@ -650,10 +659,13 @@ class CoverageService:
|
|||||||
sites: List[SiteParams],
|
sites: List[SiteParams],
|
||||||
settings: CoverageSettings,
|
settings: CoverageSettings,
|
||||||
cancel_token: Optional[CancellationToken] = None,
|
cancel_token: Optional[CancellationToken] = None,
|
||||||
|
progress_fn: Optional[Callable[[str, float], None]] = None,
|
||||||
) -> List[CoveragePoint]:
|
) -> List[CoveragePoint]:
|
||||||
"""
|
"""
|
||||||
Calculate combined coverage from multiple sites
|
Calculate combined coverage from multiple sites
|
||||||
Best server (strongest signal) wins at each point
|
Best server (strongest signal) wins at each point
|
||||||
|
|
||||||
|
progress_fn(phase, pct): optional callback for progress updates (0.0-1.0).
|
||||||
"""
|
"""
|
||||||
if not sites:
|
if not sites:
|
||||||
return []
|
return []
|
||||||
@@ -661,10 +673,26 @@ class CoverageService:
|
|||||||
# Apply preset once
|
# Apply preset once
|
||||||
settings = apply_preset(settings)
|
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
|
# Get all individual coverages
|
||||||
all_coverages = await asyncio.gather(*[
|
all_coverages = await asyncio.gather(*[
|
||||||
self.calculate_coverage(site, settings, cancel_token)
|
self.calculate_coverage(
|
||||||
for site in sites
|
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
|
# Combine by best signal
|
||||||
@@ -751,7 +779,8 @@ class CoverageService:
|
|||||||
points = []
|
points = []
|
||||||
timing = {"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
timing = {"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||||
"dominant_path": 0.0, "street_canyon": 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)
|
total = len(grid)
|
||||||
log_interval = max(1, total // 20)
|
log_interval = max(1, total // 20)
|
||||||
|
|
||||||
@@ -901,7 +930,6 @@ class CoverageService:
|
|||||||
|
|
||||||
# LOD_NONE: skip dominant path entirely for distant points (>3km)
|
# LOD_NONE: skip dominant path entirely for distant points (>3km)
|
||||||
if lod == LODLevel.NONE:
|
if lod == LODLevel.NONE:
|
||||||
timing.setdefault("lod_none", 0)
|
|
||||||
timing["lod_none"] += 1
|
timing["lod_none"] += 1
|
||||||
else:
|
else:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -909,12 +937,10 @@ class CoverageService:
|
|||||||
# LOD_SIMPLIFIED: limit buildings for mid-range points (1.5-3km)
|
# LOD_SIMPLIFIED: limit buildings for mid-range points (1.5-3km)
|
||||||
dp_buildings = nearby_buildings
|
dp_buildings = nearby_buildings
|
||||||
if lod == LODLevel.SIMPLIFIED:
|
if lod == LODLevel.SIMPLIFIED:
|
||||||
timing.setdefault("lod_simplified", 0)
|
|
||||||
timing["lod_simplified"] += 1
|
timing["lod_simplified"] += 1
|
||||||
if len(nearby_buildings) > SIMPLIFIED_MAX_BUILDINGS:
|
if len(nearby_buildings) > SIMPLIFIED_MAX_BUILDINGS:
|
||||||
dp_buildings = nearby_buildings[:SIMPLIFIED_MAX_BUILDINGS]
|
dp_buildings = nearby_buildings[:SIMPLIFIED_MAX_BUILDINGS]
|
||||||
else:
|
else:
|
||||||
timing.setdefault("lod_full", 0)
|
|
||||||
timing["lod_full"] += 1
|
timing["lod_full"] += 1
|
||||||
|
|
||||||
# nearby_buildings already filtered via spatial index —
|
# nearby_buildings already filtered via spatial index —
|
||||||
|
|||||||
@@ -164,11 +164,16 @@ except ImportError:
|
|||||||
ray = None # type: ignore
|
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_spatial_idx = None
|
||||||
_worker_cache_key: Optional[str] = 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):
|
def _ray_process_chunk_impl(chunk, terrain_cache, buildings, osm_data, config):
|
||||||
"""Implementation: process a chunk of (lat, lon, elevation) tuples.
|
"""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,
|
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||||
"dominant_path": 0.0, "street_canyon": 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
precomputed = config.get('precomputed')
|
precomputed = config.get('precomputed')
|
||||||
@@ -238,9 +244,14 @@ if RAY_AVAILABLE:
|
|||||||
|
|
||||||
|
|
||||||
def get_cpu_count() -> int:
|
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:
|
try:
|
||||||
return min(mp.cpu_count() or 4, 14)
|
return min(mp.cpu_count() or 4, 6)
|
||||||
except Exception:
|
except Exception:
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
@@ -327,8 +338,25 @@ def calculate_coverage_parallel(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_fn(f"Ray execution failed: {e} — falling back to sequential")
|
log_fn(f"Ray execution failed: {e} — falling back to sequential")
|
||||||
|
|
||||||
# Fallback: ProcessPoolExecutor with reduced workers to avoid MemoryError
|
# Fallback: ProcessPoolExecutor (shared memory eliminates per-chunk pickle)
|
||||||
pool_workers = min(num_workers, 6)
|
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:
|
if pool_workers > 1 and total_points > 100:
|
||||||
try:
|
try:
|
||||||
return _calculate_with_process_pool(
|
return _calculate_with_process_pool(
|
||||||
@@ -338,6 +366,8 @@ def calculate_coverage_parallel(
|
|||||||
pool_workers, log_fn, cancel_token, precomputed,
|
pool_workers, log_fn, cancel_token, precomputed,
|
||||||
progress_fn,
|
progress_fn,
|
||||||
)
|
)
|
||||||
|
except (MemoryError, OSError) as e:
|
||||||
|
log_fn(f"ProcessPool OOM/OS error: {e} — falling back to sequential")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_fn(f"ProcessPool failed: {e} — falling back to sequential")
|
log_fn(f"ProcessPool failed: {e} — falling back to sequential")
|
||||||
|
|
||||||
@@ -396,8 +426,8 @@ def _calculate_with_ray(
|
|||||||
for lat, lon in grid
|
for lat, lon in grid
|
||||||
]
|
]
|
||||||
|
|
||||||
# ~4 chunks per worker for granular progress
|
# Larger chunks to amortize IPC overhead (was num_workers*4)
|
||||||
chunk_size = max(1, len(items) // (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)]
|
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")
|
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,
|
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||||
"dominant_path": 0.0, "street_canyon": 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
precomputed = config.get('precomputed')
|
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
|
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):
|
def _pool_worker_shm_chunk(args):
|
||||||
"""Worker function that reads terrain from shared memory instead of pickle."""
|
"""Worker function that reads terrain from shared memory instead of pickle."""
|
||||||
import multiprocessing.shared_memory as shm_mod
|
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,
|
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||||
"dominant_path": 0.0, "street_canyon": 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
precomputed = config.get('precomputed')
|
precomputed = config.get('precomputed')
|
||||||
@@ -607,6 +661,200 @@ def _pool_worker_shm_chunk(args):
|
|||||||
return results
|
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(
|
def _calculate_with_process_pool(
|
||||||
grid, point_elevations, site_dict, settings_dict,
|
grid, point_elevations, site_dict, settings_dict,
|
||||||
terrain_cache, buildings, streets, water_bodies,
|
terrain_cache, buildings, streets, water_bodies,
|
||||||
@@ -616,23 +864,28 @@ def _calculate_with_process_pool(
|
|||||||
):
|
):
|
||||||
"""Execute using ProcessPoolExecutor.
|
"""Execute using ProcessPoolExecutor.
|
||||||
|
|
||||||
Uses shared memory for terrain tiles (zero-copy numpy views) to reduce
|
Uses shared memory for terrain tiles (zero-copy numpy views), buildings,
|
||||||
memory usage compared to pickling full terrain arrays per worker.
|
and OSM data (pickle-once, read-many) to eliminate per-chunk serialization
|
||||||
|
overhead.
|
||||||
"""
|
"""
|
||||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||||
|
|
||||||
total_points = len(grid)
|
total_points = len(grid)
|
||||||
|
|
||||||
# Estimate pickle size for building data and cap workers accordingly
|
|
||||||
building_count = len(buildings)
|
building_count = len(buildings)
|
||||||
if building_count > 10000:
|
data_items = building_count + len(streets) + len(water_bodies) + len(vegetation_areas)
|
||||||
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)
|
|
||||||
|
|
||||||
log_fn(f"ProcessPool mode: {total_points} points, {num_workers} workers, "
|
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
|
# Store terrain tiles in shared memory
|
||||||
shm_blocks = []
|
shm_blocks = []
|
||||||
@@ -652,12 +905,31 @@ def _calculate_with_process_pool(
|
|||||||
log_fn(f"Shared memory setup failed ({e}), using pickle fallback")
|
log_fn(f"Shared memory setup failed ({e}), using pickle fallback")
|
||||||
use_shm = False
|
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 = [
|
items = [
|
||||||
(lat, lon, point_elevations.get((lat, lon), 0.0))
|
(lat, lon, point_elevations.get((lat, lon), 0.0))
|
||||||
for lat, lon in grid
|
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)]
|
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")
|
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)
|
pool = ProcessPoolExecutor(max_workers=num_workers, mp_context=ctx)
|
||||||
_set_active_pool(pool)
|
_set_active_pool(pool)
|
||||||
|
|
||||||
if use_shm:
|
if use_shm and shared_data_refs:
|
||||||
# Shared memory path: pass shm refs instead of terrain data
|
# 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
|
worker_fn = _pool_worker_shm_chunk
|
||||||
futures = {
|
futures = {
|
||||||
pool.submit(
|
pool.submit(
|
||||||
@@ -695,8 +980,9 @@ def _calculate_with_process_pool(
|
|||||||
): i
|
): i
|
||||||
for i, chunk in enumerate(chunks)
|
for i, chunk in enumerate(chunks)
|
||||||
}
|
}
|
||||||
else:
|
elif data_items <= 2000:
|
||||||
# Pickle fallback path
|
# Full pickle fallback — only safe for small datasets
|
||||||
|
log_fn(f"Full pickle path (small data: {data_items} items)")
|
||||||
futures = {
|
futures = {
|
||||||
pool.submit(
|
pool.submit(
|
||||||
_pool_worker_process_chunk,
|
_pool_worker_process_chunk,
|
||||||
@@ -704,6 +990,14 @@ def _calculate_with_process_pool(
|
|||||||
): i
|
): i
|
||||||
for i, chunk in enumerate(chunks)
|
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
|
completed_chunks = 0
|
||||||
for future in as_completed(futures):
|
for future in as_completed(futures):
|
||||||
@@ -730,6 +1024,9 @@ def _calculate_with_process_pool(
|
|||||||
if progress_fn:
|
if progress_fn:
|
||||||
progress_fn("Calculating coverage", 0.40 + 0.55 * (completed_chunks / len(chunks)))
|
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:
|
except Exception as e:
|
||||||
log_fn(f"ProcessPool error: {e}")
|
log_fn(f"ProcessPool error: {e}")
|
||||||
|
|
||||||
@@ -748,8 +1045,22 @@ def _calculate_with_process_pool(
|
|||||||
block.unlink()
|
block.unlink()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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
|
# Force garbage collection to release memory from workers
|
||||||
gc.collect()
|
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
|
calc_time = time.time() - t_calc
|
||||||
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
|
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
|
||||||
@@ -758,7 +1069,11 @@ def _calculate_with_process_pool(
|
|||||||
timing = {
|
timing = {
|
||||||
"parallel_total": calc_time,
|
"parallel_total": calc_time,
|
||||||
"workers": num_workers,
|
"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
|
return all_results, timing
|
||||||
|
|
||||||
@@ -791,6 +1106,7 @@ def _calculate_sequential(
|
|||||||
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||||
"dominant_path": 0.0, "street_canyon": 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import SiteList from '@/components/panels/SiteList.tsx';
|
|||||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||||
import CoverageStats from '@/components/panels/CoverageStats.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 SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||||
import type { SiteFormValues } 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;
|
const currentSettings = useCoverageStore.getState().settings;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (currentSettings.radius > 100) {
|
if (currentSettings.radius > 50) {
|
||||||
addToast('Radius too large (max 100km)', 'error');
|
addToast('Radius too large (max 50km)', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentSettings.resolution < 50) {
|
if (currentSettings.resolution < 50) {
|
||||||
@@ -406,9 +408,17 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
await calculateCoverageApi();
|
await calculateCoverageApi();
|
||||||
|
|
||||||
// Check result after calculation
|
// After calculateCoverageApi returns, check if WS took over.
|
||||||
const result = useCoverageStore.getState().result;
|
// In WS mode, the function returns immediately and result arrives asynchronously.
|
||||||
const error = useCoverageStore.getState().error;
|
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) {
|
if (error) {
|
||||||
let userMessage = 'Calculation failed';
|
let userMessage = 'Calculation failed';
|
||||||
@@ -666,6 +676,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
<HeatmapLegend />
|
<HeatmapLegend />
|
||||||
|
<ResultsPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
@@ -706,14 +717,15 @@ export default function App() {
|
|||||||
<NumberInput
|
<NumberInput
|
||||||
label="Radius"
|
label="Radius"
|
||||||
value={settings.radius}
|
value={settings.radius}
|
||||||
onChange={(v) =>
|
onChange={(v) => {
|
||||||
useCoverageStore.getState().updateSettings({ radius: v })
|
const clamped = Math.min(v, 50);
|
||||||
}
|
useCoverageStore.getState().updateSettings({ radius: clamped });
|
||||||
|
}}
|
||||||
min={1}
|
min={1}
|
||||||
max={100}
|
max={50}
|
||||||
step={5}
|
step={5}
|
||||||
unit="km"
|
unit="km"
|
||||||
hint="Calculation area around each site"
|
hint="Calculation area around each site (max 50km)"
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Resolution"
|
label="Resolution"
|
||||||
@@ -1174,6 +1186,9 @@ export default function App() {
|
|||||||
modelsUsed={coverageResult?.modelsUsed}
|
modelsUsed={coverageResult?.modelsUsed}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Session history */}
|
||||||
|
<HistoryPanel />
|
||||||
|
|
||||||
{/* Export coverage data */}
|
{/* Export coverage data */}
|
||||||
<ExportPanel />
|
<ExportPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function CoverageBoundary({
|
|||||||
points,
|
points,
|
||||||
visible,
|
visible,
|
||||||
resolution,
|
resolution,
|
||||||
color = '#7c3aed', // purple-600 — visible against both map and orange gradient
|
color = '#ffffff', // white — visible against red-to-blue gradient
|
||||||
weight = 2,
|
weight = 2,
|
||||||
}: CoverageBoundaryProps) {
|
}: CoverageBoundaryProps) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import { useSitesStore } from '@/store/sites.ts';
|
|||||||
|
|
||||||
const LEGEND_STEPS = [
|
const LEGEND_STEPS = [
|
||||||
{ rsrp: -130, label: 'No Service' },
|
{ rsrp: -130, label: 'No Service' },
|
||||||
{ rsrp: -110, label: 'Very Weak' },
|
{ rsrp: -110, label: 'Weak' },
|
||||||
{ rsrp: -100, label: 'Weak' },
|
{ rsrp: -100, label: 'Fair' },
|
||||||
{ rsrp: -90, label: 'Fair' },
|
{ rsrp: -85, label: 'Good' },
|
||||||
{ rsrp: -80, label: 'Good' },
|
{ rsrp: -70, label: 'Excellent' },
|
||||||
{ rsrp: -70, label: 'Strong' },
|
{ rsrp: -50, label: 'Max' },
|
||||||
{ rsrp: -50, label: 'Excellent' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
||||||
@@ -106,9 +105,9 @@ export default function HeatmapLegend() {
|
|||||||
|
|
||||||
{/* Cutoff indicator + below-threshold (dimmed) */}
|
{/* Cutoff indicator + below-threshold (dimmed) */}
|
||||||
{belowThreshold.length > 0 && (
|
{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">
|
<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)
|
─ ─ Coverage boundary ({threshold} dBm)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import NumberInput from '@/components/ui/NumberInput.tsx';
|
import NumberInput from '@/components/ui/NumberInput.tsx';
|
||||||
import FrequencySelector from '@/components/panels/FrequencySelector.tsx';
|
|
||||||
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
|
||||||
import ModalBackdrop from './ModalBackdrop.tsx';
|
import ModalBackdrop from './ModalBackdrop.tsx';
|
||||||
|
|
||||||
@@ -31,6 +30,7 @@ interface SiteConfigModalProps {
|
|||||||
const TEMPLATES = {
|
const TEMPLATES = {
|
||||||
limesdr: {
|
limesdr: {
|
||||||
label: 'LimeSDR',
|
label: 'LimeSDR',
|
||||||
|
tooltip: 'SDR dev board — low power, short range testing (20 dBm, 2 dBi, 1800 MHz)',
|
||||||
style: 'purple',
|
style: 'purple',
|
||||||
name: 'LimeSDR Mini',
|
name: 'LimeSDR Mini',
|
||||||
power: 20,
|
power: 20,
|
||||||
@@ -41,6 +41,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
lowBBU: {
|
lowBBU: {
|
||||||
label: 'Low BBU',
|
label: 'Low BBU',
|
||||||
|
tooltip: 'Low-power baseband unit — suburban/campus coverage (40 dBm, 8 dBi, 1800 MHz)',
|
||||||
style: 'green',
|
style: 'green',
|
||||||
name: 'Low Power BBU',
|
name: 'Low Power BBU',
|
||||||
power: 40,
|
power: 40,
|
||||||
@@ -51,6 +52,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
highBBU: {
|
highBBU: {
|
||||||
label: 'High BBU',
|
label: 'High BBU',
|
||||||
|
tooltip: 'High-power BBU — urban macro sector (43 dBm, 15 dBi, 65\u00B0 sector)',
|
||||||
style: 'orange',
|
style: 'orange',
|
||||||
name: 'High Power BBU',
|
name: 'High Power BBU',
|
||||||
power: 43,
|
power: 43,
|
||||||
@@ -63,6 +65,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
urbanMacro: {
|
urbanMacro: {
|
||||||
label: 'Urban Macro',
|
label: 'Urban Macro',
|
||||||
|
tooltip: 'Standard urban macro site — rooftop/tower sector (43 dBm, 18 dBi, 65\u00B0 sector)',
|
||||||
style: 'blue',
|
style: 'blue',
|
||||||
name: 'Urban Macro Site',
|
name: 'Urban Macro Site',
|
||||||
power: 43,
|
power: 43,
|
||||||
@@ -75,6 +78,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
ruralTower: {
|
ruralTower: {
|
||||||
label: 'Rural Tower',
|
label: 'Rural Tower',
|
||||||
|
tooltip: 'Rural high tower — long range 800 MHz omni coverage (46 dBm, 8 dBi, 50m)',
|
||||||
style: 'emerald',
|
style: 'emerald',
|
||||||
name: 'Rural Tower',
|
name: 'Rural Tower',
|
||||||
power: 46,
|
power: 46,
|
||||||
@@ -85,6 +89,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
smallCell: {
|
smallCell: {
|
||||||
label: 'Small Cell',
|
label: 'Small Cell',
|
||||||
|
tooltip: 'Urban small cell — street-level high capacity (30 dBm, 12 dBi, 2600 MHz)',
|
||||||
style: 'cyan',
|
style: 'cyan',
|
||||||
name: 'Small Cell',
|
name: 'Small Cell',
|
||||||
power: 30,
|
power: 30,
|
||||||
@@ -97,6 +102,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
indoorDAS: {
|
indoorDAS: {
|
||||||
label: 'Indoor DAS',
|
label: 'Indoor DAS',
|
||||||
|
tooltip: 'Indoor distributed antenna — in-building coverage (23 dBm, 2 dBi, 2100 MHz)',
|
||||||
style: 'rose',
|
style: 'rose',
|
||||||
name: 'Indoor DAS',
|
name: 'Indoor DAS',
|
||||||
power: 23,
|
power: 23,
|
||||||
@@ -107,6 +113,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
uhfTactical: {
|
uhfTactical: {
|
||||||
label: 'UHF Tactical',
|
label: 'UHF Tactical',
|
||||||
|
tooltip: 'UHF tactical radio — man-portable field comms (25 dBm, 3 dBi, 450 MHz)',
|
||||||
style: 'amber',
|
style: 'amber',
|
||||||
name: 'UHF Tactical Radio',
|
name: 'UHF Tactical Radio',
|
||||||
power: 25,
|
power: 25,
|
||||||
@@ -117,6 +124,7 @@ const TEMPLATES = {
|
|||||||
},
|
},
|
||||||
vhfRepeater: {
|
vhfRepeater: {
|
||||||
label: 'VHF Repeater',
|
label: 'VHF Repeater',
|
||||||
|
tooltip: 'VHF repeater — long range voice/data relay (40 dBm, 6 dBi, 150 MHz)',
|
||||||
style: 'teal',
|
style: 'teal',
|
||||||
name: 'VHF Repeater',
|
name: 'VHF Repeater',
|
||||||
power: 40,
|
power: 40,
|
||||||
@@ -203,8 +211,8 @@ export default function SiteConfigModal({
|
|||||||
if (form.power < 10 || form.power > 50) {
|
if (form.power < 10 || form.power > 50) {
|
||||||
newErrors.power = 'Power must be 10-50 dBm';
|
newErrors.power = 'Power must be 10-50 dBm';
|
||||||
}
|
}
|
||||||
if (form.gain < 0 || form.gain > 25) {
|
if (form.gain < 0 || form.gain > 30) {
|
||||||
newErrors.gain = 'Gain must be 0-25 dBi';
|
newErrors.gain = 'Gain must be 0-30 dBi';
|
||||||
}
|
}
|
||||||
if (form.frequency < 100 || form.frequency > 6000) {
|
if (form.frequency < 100 || form.frequency > 6000) {
|
||||||
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
newErrors.frequency = 'Frequency must be 100-6000 MHz';
|
||||||
@@ -360,20 +368,20 @@ export default function SiteConfigModal({
|
|||||||
label="Antenna Gain"
|
label="Antenna Gain"
|
||||||
value={form.gain}
|
value={form.gain}
|
||||||
min={0}
|
min={0}
|
||||||
max={25}
|
max={30}
|
||||||
step={0.5}
|
step={0.5}
|
||||||
unit="dBi"
|
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)}
|
onChange={(v) => updateField('gain', v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Frequency */}
|
{/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */}
|
||||||
<FrequencySelector
|
|
||||||
value={form.frequency}
|
|
||||||
onChange={(v) => updateField('frequency', v)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Band panel — UHF/VHF/LTE/5G grouped selector */}
|
|
||||||
<FrequencyBandPanel
|
<FrequencyBandPanel
|
||||||
value={form.frequency}
|
value={form.frequency}
|
||||||
onChange={(v) => updateField('frequency', v)}
|
onChange={(v) => updateField('frequency', v)}
|
||||||
@@ -485,6 +493,7 @@ export default function SiteConfigModal({
|
|||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
|
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]
|
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
|
||||||
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function estimateAreaKm2(pointCount: number, resolutionM: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ label: 'Excellent', threshold: -70, color: 'bg-green-500' },
|
{ label: 'Excellent', threshold: -70, color: 'bg-blue-500' },
|
||||||
{ label: 'Good', threshold: -85, color: 'bg-lime-500' },
|
{ label: 'Good', threshold: -85, color: 'bg-green-500' },
|
||||||
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
|
||||||
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* and propagation model info for each band.
|
* and propagation model info for each band.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
|
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
|
||||||
import type { FrequencyBand } from '@/types/index.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) {
|
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
|
||||||
const currentBand = getBandForFrequency(value);
|
const currentBand = getBandForFrequency(value);
|
||||||
|
const [customInput, setCustomInput] = useState('');
|
||||||
|
|
||||||
|
const handleCustomSubmit = () => {
|
||||||
|
const parsed = parseInt(customInput, 10);
|
||||||
|
if (parsed > 0 && parsed <= 100000) {
|
||||||
|
onChange(parsed);
|
||||||
|
setCustomInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
<div className="flex items-center justify-between">
|
||||||
Frequency Bands
|
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
|
||||||
|
Operating Frequency
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 dark:text-dark-muted">
|
||||||
|
{value} MHz
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
|
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
|
||||||
@@ -139,6 +154,28 @@ export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPan
|
|||||||
</div>
|
</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>
|
</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 {
|
class WebSocketService {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
private _connected = false;
|
private _connected = false;
|
||||||
private _pendingCalcs = new Map<string, PendingCalc>();
|
private _pendingCalcs = new Map<string, PendingCalc>();
|
||||||
private _connectionListeners = new Set<ConnectionCallback>();
|
private _connectionListeners = new Set<ConnectionCallback>();
|
||||||
@@ -70,10 +71,20 @@ class WebSocketService {
|
|||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
this._setConnected(true);
|
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.ws.onclose = () => {
|
||||||
this._setConnected(false);
|
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);
|
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 {
|
disconnect(): void {
|
||||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||||
|
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
|
||||||
|
this._failPendingCalcs('WebSocket disconnected');
|
||||||
this.ws?.close();
|
this.ws?.close();
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
this._setConnected(false);
|
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 { wsService } from '@/services/websocket.ts';
|
||||||
import type { WSProgress } from '@/services/websocket.ts';
|
import type { WSProgress } from '@/services/websocket.ts';
|
||||||
import { useSitesStore } from '@/store/sites.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 { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||||
|
|
||||||
@@ -49,7 +52,7 @@ function buildApiSettings(settings: CoverageSettings) {
|
|||||||
return {
|
return {
|
||||||
radius: settings.radius * 1000, // km → meters
|
radius: settings.radius * 1000, // km → meters
|
||||||
resolution: settings.resolution,
|
resolution: settings.resolution,
|
||||||
min_signal: settings.rsrpThreshold,
|
min_signal: -130, // Send all useful points; frontend filters visually via rsrpThreshold
|
||||||
preset: settings.preset,
|
preset: settings.preset,
|
||||||
use_terrain: settings.use_terrain,
|
use_terrain: settings.use_terrain,
|
||||||
use_buildings: settings.use_buildings,
|
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) => ({
|
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||||
result: null,
|
result: null,
|
||||||
isCalculating: false,
|
isCalculating: false,
|
||||||
@@ -163,12 +204,36 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
|||||||
apiSettings as unknown as Record<string, unknown>,
|
apiSettings as unknown as Record<string, unknown>,
|
||||||
// onResult
|
// onResult
|
||||||
(data) => {
|
(data) => {
|
||||||
const result = responseToResult(data, settings);
|
try {
|
||||||
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
|
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
|
// onError
|
||||||
(error) => {
|
(error) => {
|
||||||
set({ isCalculating: false, error, progress: null, activeCalcId: null });
|
set({ isCalculating: false, error, progress: null, activeCalcId: null });
|
||||||
|
useToastStore.getState().addToast(`Calculation failed: ${error}`, 'error');
|
||||||
},
|
},
|
||||||
// onProgress
|
// onProgress
|
||||||
(progress) => {
|
(progress) => {
|
||||||
@@ -191,6 +256,10 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
|
|||||||
|
|
||||||
const result = responseToResult(response, settings);
|
const result = responseToResult(response, settings);
|
||||||
set({ result, isCalculating: false, error: null });
|
set({ result, isCalculating: false, error: null });
|
||||||
|
// Push to session history
|
||||||
|
if (result.points.length > 0) {
|
||||||
|
useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
set({ isCalculating: false });
|
set({ isCalculating: false });
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* RSRP → color mapping with smooth gradient interpolation.
|
* RSRP → color mapping with smooth gradient interpolation.
|
||||||
*
|
*
|
||||||
* Purple → Orange palette:
|
* CloudRF-style Red → Blue palette:
|
||||||
* -130 dBm = deep purple (no service)
|
* -130 dBm = dark red (no service)
|
||||||
* -90 dBm = peach (fair)
|
* -100 dBm = yellow (fair)
|
||||||
* -50 dBm = bright orange (excellent)
|
* -70 dBm = green (good)
|
||||||
|
* -50 dBm = deep blue (excellent)
|
||||||
*
|
*
|
||||||
* All functions are pure and allocation-free on the hot path
|
* All functions are pure and allocation-free on the hot path
|
||||||
* (pre-built lookup table for fast per-pixel color resolution).
|
* (pre-built lookup table for fast per-pixel color resolution).
|
||||||
@@ -18,14 +19,13 @@ interface GradientStop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GRADIENT_STOPS: GradientStop[] = [
|
const GRADIENT_STOPS: GradientStop[] = [
|
||||||
{ value: 0.0, r: 26, g: 0, b: 51 }, // #1a0033 — deep purple (no service)
|
{ value: 0.0, r: 127, g: 0, b: 0 }, // #7f0000 — dark red (no service)
|
||||||
{ value: 0.15, r: 74, g: 20, b: 140 }, // #4a148c — dark purple
|
{ value: 0.15, r: 239, g: 68, b: 68 }, // #EF4444 — red (very weak)
|
||||||
{ value: 0.30, r: 123, g: 31, b: 162 }, // #7b1fa2 — purple (very weak)
|
{ value: 0.30, r: 249, g: 115, b: 22 }, // #F97316 — orange (weak)
|
||||||
{ value: 0.45, r: 171, g: 71, b: 188 }, // #ab47bc — light purple (weak)
|
{ value: 0.50, r: 234, g: 179, b: 8 }, // #EAB308 — yellow (fair)
|
||||||
{ value: 0.60, r: 255, g: 138, b: 101 }, // #ff8a65 — peach (fair)
|
{ value: 0.70, r: 34, g: 197, b: 94 }, // #22C55E — green (good)
|
||||||
{ value: 0.75, r: 255, g: 111, b: 0 }, // #ff6f00 — dark orange (good)
|
{ value: 0.85, r: 59, g: 130, b: 246 }, // #3B82F6 — blue (strong)
|
||||||
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange (strong)
|
{ value: 1.0, r: 37, g: 99, b: 235 }, // #2563EB — deep blue (excellent)
|
||||||
{ value: 1.0, r: 255, g: 183, b: 77 }, // #ffb74d — bright orange (excellent)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user