@mytec: iter2.3 multithreading p1 done

This commit is contained in:
2026-01-31 20:54:14 +02:00
parent 26f8067c94
commit 3b010fed83
10 changed files with 937 additions and 27 deletions

View File

@@ -0,0 +1,543 @@
# RFCP Phase 2.3: Performance Optimization
**Date:** January 31, 2025
**Type:** Performance & Parallelization
**Estimated:** 8-12 hours
**Priority:** HIGH — enables practical use of Detailed preset
**Depends on:** Phase 2.2 (Offline Caching)
---
## 🎯 Goal
Make Detailed preset usable by parallelizing calculations across CPU cores and optionally GPU. Target: **10-50x speedup**.
---
## 📊 Current Performance
| Preset | Points | Current Time | Target Time |
|--------|--------|--------------|-------------|
| Fast | 868 | 0.03s | 0.03s ✅ |
| Standard | 868 | 13s | 5s |
| Detailed | 868 | 300s+ (timeout) | 30s |
**Bottleneck Analysis:**
```
[DOMINANT_PATH] Point #1: line_bldgs=646, refl_bldgs=302
- 868 points × 700 buildings × geometry = millions of operations
- Single-threaded Python
- 2 sec/point → 868 × 2 = 1736 sec theoretical
```
---
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Coverage Calculation │
├─────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: OSM Fetch (async, cached) → unchanged │
│ Phase 2: Terrain Pre-load (async) → unchanged │
│ Phase 3: Point Calculation → PARALLELIZE │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ProcessPoolExecutor │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Core 1 │ │ Core 2 │ │ Core 3 │ │ Core N │ │ │
│ │ │ pts 0-61│ │pts 62-123│ │pts 124..│ │ pts ... │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Optional: GPU Acceleration │ │
│ │ - Path loss matrix calculation (NumPy → CuPy) │ │
│ │ - Batch terrain lookups │ │
│ │ - Vectorized distance calculations │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## ✅ Tasks
### Task 2.3.1: Multiprocessing Infrastructure (3-4 hours)
**Problem:** Python GIL prevents true parallelism with threads. Need processes.
**Create `backend/app/services/parallel_coverage_service.py`:**
```python
import os
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import List, Dict, Any, Tuple
import time
# Shared data for worker processes (loaded once per process)
_worker_data = {}
def _init_worker(terrain_cache: Dict, buildings: List, spatial_index_data: Dict, settings_dict: Dict):
"""Initialize worker process with shared data."""
global _worker_data
_worker_data = {
'terrain_cache': terrain_cache,
'buildings': buildings,
'spatial_index': rebuild_spatial_index(spatial_index_data),
'settings': settings_dict,
}
# Import heavy modules inside worker to avoid pickle issues
from app.services.terrain_service import TerrainService
from app.services.los_service import LOSService
from app.services.dominant_path_service import DominantPathService
_worker_data['terrain_service'] = TerrainService()
_worker_data['terrain_service']._tile_cache = terrain_cache
_worker_data['los_service'] = LOSService(_worker_data['terrain_service'])
_worker_data['dominant_path_service'] = DominantPathService(
_worker_data['terrain_service'],
_worker_data['los_service']
)
def _calculate_point_worker(args: Tuple) -> Dict:
"""Worker function for single point calculation."""
global _worker_data
lat, lon, site_lat, site_lon, site_elevation, point_elevation = args
# Use pre-initialized services
terrain = _worker_data['terrain_service']
los = _worker_data['los_service']
dominant = _worker_data['dominant_path_service']
settings = _worker_data['settings']
buildings = _worker_data['buildings']
spatial_idx = _worker_data['spatial_index']
# ... calculation logic (copy from _calculate_point_sync)
return {
'lat': lat,
'lon': lon,
'rsrp': rsrp,
'distance': distance,
# ... other fields
}
class ParallelCoverageService:
"""Coverage calculation with multiprocessing."""
def __init__(self):
# Detect available cores
self.num_workers = min(mp.cpu_count(), 14) # Cap at 14
print(f"[Coverage] Parallel mode: {self.num_workers} workers")
async def calculate_parallel(
self,
sites: List,
settings: CoverageSettings,
terrain_cache: Dict,
buildings: List,
spatial_index_data: Dict,
) -> List[Dict]:
"""Calculate coverage using multiple processes."""
# Prepare grid
grid = self._generate_grid(sites, settings)
total_points = len(grid)
print(f"[Coverage] Starting parallel calculation: {total_points} points, {self.num_workers} workers")
# Pre-compute point elevations
point_elevations = {(lat, lon): elev for lat, lon, elev in grid_with_elevations}
# Prepare arguments for workers
work_items = [
(lat, lon, site.lat, site.lon, site_elevation, point_elevations.get((lat, lon), 0))
for lat, lon in grid
]
# Run in process pool
results = []
start_time = time.time()
with ProcessPoolExecutor(
max_workers=self.num_workers,
initializer=_init_worker,
initargs=(terrain_cache, buildings, spatial_index_data, settings.dict())
) as executor:
# Submit all tasks
futures = {executor.submit(_calculate_point_worker, item): i
for i, item in enumerate(work_items)}
# Collect results with progress
completed = 0
for future in as_completed(futures):
result = future.result()
results.append(result)
completed += 1
if completed % (total_points // 10) == 0:
elapsed = time.time() - start_time
rate = completed / elapsed
eta = (total_points - completed) / rate
print(f"[Coverage] Progress: {completed}/{total_points} ({100*completed//total_points}%) - ETA: {eta:.1f}s")
elapsed = time.time() - start_time
print(f"[Coverage] Parallel calculation done: {elapsed:.1f}s ({elapsed/total_points*1000:.1f}ms/point)")
return results
```
---
### Task 2.3.2: Data Serialization for Workers (2-3 hours)
**Problem:** Each worker process needs access to terrain cache, buildings, spatial index. Can't share directly.
**Solutions:**
1. **Shared Memory (Python 3.8+):**
```python
from multiprocessing import shared_memory
import numpy as np
# Create shared terrain cache
terrain_shm = shared_memory.SharedMemory(create=True, size=terrain_array.nbytes)
terrain_shared = np.ndarray(terrain_array.shape, dtype=terrain_array.dtype, buffer=terrain_shm.buf)
terrain_shared[:] = terrain_array[:]
```
2. **Memory-mapped files:**
```python
import mmap
import numpy as np
# Save terrain to mmap file
terrain_mmap = np.memmap('terrain_cache.dat', dtype='int16', mode='w+', shape=(3601, 3601))
terrain_mmap[:] = terrain_data[:]
terrain_mmap.flush()
# Workers read from same file
worker_terrain = np.memmap('terrain_cache.dat', dtype='int16', mode='r', shape=(3601, 3601))
```
3. **Pickle once, load in each worker:**
```python
# Main process saves data
import pickle
with open('worker_data.pkl', 'wb') as f:
pickle.dump({'terrain': terrain_cache, 'buildings': buildings}, f)
# Worker loads once at init
def _init_worker(data_path):
global _worker_data
with open(data_path, 'rb') as f:
_worker_data = pickle.load(f)
```
**Recommendation:** Start with pickle (simplest), optimize with mmap if needed.
---
### Task 2.3.3: Integrate Parallel Service (2 hours)
**Update `coverage_service.py`:**
```python
class CoverageService:
def __init__(self):
self.parallel_service = ParallelCoverageService()
self.use_parallel = True # Can be toggled
self.parallel_threshold = 100 # Use parallel for > 100 points
async def calculate(self, sites, settings):
grid = self._generate_grid(sites, settings)
# Decide execution mode
if self.use_parallel and len(grid) > self.parallel_threshold:
return await self._calculate_parallel(sites, settings, grid)
else:
return await self._calculate_sequential(sites, settings, grid)
async def _calculate_parallel(self, sites, settings, grid):
# Phase 1: OSM fetch (same as before)
buildings, streets, water, vegetation = await self._fetch_osm_grid_aligned(...)
# Phase 2: Terrain pre-load (same as before)
await self.terrain.ensure_tiles_for_bbox(...)
terrain_cache = self.terrain._tile_cache.copy()
# Phase 3: Parallel point calculation
spatial_index_data = self._serialize_spatial_index(spatial_idx)
results = await self.parallel_service.calculate_parallel(
sites=sites,
settings=settings,
terrain_cache=terrain_cache,
buildings=buildings,
spatial_index_data=spatial_index_data,
)
return results
```
---
### Task 2.3.4: GPU Acceleration (Optional) (3-4 hours)
**Only if NVIDIA GPU detected. Use CuPy for NumPy-like GPU operations.**
**Create `backend/app/services/gpu_service.py`:**
```python
import os
# Check for GPU
GPU_AVAILABLE = False
try:
import cupy as cp
GPU_AVAILABLE = cp.cuda.runtime.getDeviceCount() > 0
if GPU_AVAILABLE:
print(f"[GPU] CUDA available: {cp.cuda.runtime.getDeviceProperties(0)['name'].decode()}")
except ImportError:
pass
class GPUService:
"""GPU-accelerated calculations using CuPy."""
def __init__(self):
self.enabled = GPU_AVAILABLE
def calculate_path_loss_batch(
self,
distances: np.ndarray, # (N,) array of distances in meters
frequency_mhz: float,
tx_height: float,
rx_height: float,
) -> np.ndarray:
"""Calculate Okumura-Hata path loss for all points at once."""
if self.enabled:
import cupy as cp
d = cp.asarray(distances)
else:
d = distances
# Okumura-Hata formula (vectorized)
d_km = d / 1000.0
f = frequency_mhz
hb = tx_height
hm = rx_height
# Urban area correction
a_hm = (1.1 * np.log10(f) - 0.7) * hm - (1.56 * np.log10(f) - 0.8)
# Path loss
L = (46.3 + 33.9 * np.log10(f) - 13.82 * np.log10(hb) - a_hm +
(44.9 - 6.55 * np.log10(hb)) * np.log10(d_km))
if self.enabled:
return cp.asnumpy(L)
return L
def calculate_distances_batch(
self,
site_lat: float,
site_lon: float,
point_lats: np.ndarray,
point_lons: np.ndarray,
) -> np.ndarray:
"""Calculate distances from site to all points (Haversine)."""
if self.enabled:
import cupy as cp
lat1 = cp.radians(site_lat)
lon1 = cp.radians(site_lon)
lat2 = cp.radians(cp.asarray(point_lats))
lon2 = cp.radians(cp.asarray(point_lons))
else:
lat1 = np.radians(site_lat)
lon1 = np.radians(site_lon)
lat2 = np.radians(point_lats)
lon2 = np.radians(point_lons)
dlat = lat2 - lat1
dlon = lon2 - lon1
a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2
c = 2 * np.arcsin(np.sqrt(a))
R = 6371000 # Earth radius in meters
distances = R * c
if self.enabled:
return cp.asnumpy(distances)
return distances
gpu_service = GPUService()
```
**Add to requirements.txt (optional):**
```
cupy-cuda12x>=12.0.0 # For CUDA 12.x
# or cupy-cuda11x>=11.0.0 # For CUDA 11.x
```
---
### Task 2.3.5: Settings UI for Parallel/GPU (1 hour)
**Add to frontend Settings panel:**
```typescript
// Performance settings
<div className="settings-section">
<h4>Performance</h4>
<label>
<input
type="checkbox"
checked={settings.useParallel}
onChange={(e) => updateSettings({ useParallel: e.target.checked })}
/>
Use parallel processing ({cpuCores} cores)
</label>
{gpuAvailable && (
<label>
<input
type="checkbox"
checked={settings.useGPU}
onChange={(e) => updateSettings({ useGPU: e.target.checked })}
/>
Use GPU acceleration ({gpuName})
</label>
)}
<div className="worker-count">
<label>Worker processes:</label>
<input
type="number"
min={1}
max={cpuCores}
value={settings.workerCount}
onChange={(e) => updateSettings({ workerCount: e.target.value })}
/>
</div>
</div>
```
**Add API endpoint for system info:**
```python
@router.get("/api/system/info")
async def get_system_info():
import multiprocessing as mp
gpu_info = None
try:
import cupy as cp
if cp.cuda.runtime.getDeviceCount() > 0:
props = cp.cuda.runtime.getDeviceProperties(0)
gpu_info = {
'name': props['name'].decode(),
'memory_mb': props['totalGlobalMem'] // (1024 * 1024),
}
except:
pass
return {
'cpu_cores': mp.cpu_count(),
'gpu': gpu_info,
'parallel_enabled': True,
'gpu_enabled': gpu_info is not None,
}
```
---
## 🧪 Testing
```bash
# Run performance test
cd installer
.\test-coverage.bat
# Expected results after optimization:
# Fast: 0.03s (unchanged)
# Standard: ~5s (was 13s)
# Detailed: ~30s (was 300s+ timeout)
```
**Benchmark script:**
```python
# test_parallel.py
import asyncio
import time
from app.services.coverage_service import coverage_service
async def benchmark():
settings = CoverageSettings(
radius=5000,
resolution=300,
preset='detailed',
)
site = Site(lat=50.45, lon=30.52, ...)
# Warm up
await coverage_service.calculate([site], settings)
# Benchmark
times = []
for i in range(3):
start = time.time()
result = await coverage_service.calculate([site], settings)
elapsed = time.time() - start
times.append(elapsed)
print(f"Run {i+1}: {elapsed:.1f}s, {len(result)} points")
print(f"Average: {sum(times)/len(times):.1f}s")
asyncio.run(benchmark())
```
---
## ✅ Success Criteria
- [ ] Multiprocessing uses all available CPU cores
- [ ] Detailed preset completes in <60s for 5km radius
- [ ] No memory leaks with large calculations
- [ ] GPU acceleration works if NVIDIA card present
- [ ] Settings UI shows core count and GPU status
- [ ] Progress indicator updates during calculation
---
## 📊 Expected Performance
| Preset | Before | After (14 cores) | After (14 cores + GPU) |
|--------|--------|------------------|------------------------|
| Fast | 0.03s | 0.03s | 0.03s |
| Standard | 13s | ~2s | ~1s |
| Detailed | 300s+ | ~25s | ~10s |
---
## 🔜 Next: Phase 2.4
- [ ] R-tree spatial index (replace grid-based)
- [ ] Simplified building geometry for distant points
- [ ] Level-of-detail (LOD) system
- [ ] Streaming results (show partial coverage while calculating)
---
**Ready for Claude Code** 🚀

View File

@@ -0,0 +1,30 @@
import multiprocessing as mp
from fastapi import APIRouter
router = APIRouter()
@router.get("/info")
async def get_system_info():
"""Return system info: CPU cores, GPU availability, parallel support."""
cpu_cores = mp.cpu_count() or 1
gpu_info = None
try:
import cupy as cp
if cp.cuda.runtime.getDeviceCount() > 0:
props = cp.cuda.runtime.getDeviceProperties(0)
gpu_info = {
"name": props["name"].decode(),
"memory_mb": props["totalGlobalMem"] // (1024 * 1024),
}
except Exception:
pass
return {
"cpu_cores": cpu_cores,
"parallel_workers": min(cpu_cores, 14),
"parallel_enabled": True,
"gpu": gpu_info,
"gpu_enabled": gpu_info is not None,
}

View File

@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.core.database import connect_to_mongo, close_mongo_connection from app.core.database import connect_to_mongo, close_mongo_connection
from app.api.routes import health, projects, terrain, coverage, regions from app.api.routes import health, projects, terrain, coverage, regions, system
@asynccontextmanager @asynccontextmanager
@@ -36,6 +36,7 @@ app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"]) app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"])
app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"]) app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"])
app.include_router(regions.router, prefix="/api/regions", tags=["regions"]) app.include_router(regions.router, prefix="/api/regions", tags=["regions"])
app.include_router(system.router, prefix="/api/system", tags=["system"])
@app.get("/") @app.get("/")

View File

@@ -53,6 +53,9 @@ from app.services.vegetation_service import vegetation_service, VegetationArea
from app.services.weather_service import weather_service from app.services.weather_service import weather_service
from app.services.indoor_service import indoor_service from app.services.indoor_service import indoor_service
from app.services.atmospheric_service import atmospheric_service from app.services.atmospheric_service import atmospheric_service
from app.services.parallel_coverage_service import (
calculate_coverage_parallel, get_cpu_count
)
class CoveragePoint(BaseModel): class CoveragePoint(BaseModel):
@@ -349,19 +352,47 @@ class CoverageService:
f"pre-computed {len(grid)} elevations") f"pre-computed {len(grid)} elevations")
_clog(f"━━━ PHASE 2 done: {terrain_time:.1f}s ━━━") _clog(f"━━━ PHASE 2 done: {terrain_time:.1f}s ━━━")
# ━━━ PHASE 3: Point calculation (sync, in thread pool) ━━━ # ━━━ PHASE 3: Point calculation ━━━
_clog(f"━━━ PHASE 3: Calculating {len(grid)} points (threaded) ━━━")
dominant_path_service._log_count = 0 # Reset diagnostic counter dominant_path_service._log_count = 0 # Reset diagnostic counter
t_points = time.time() t_points = time.time()
loop = asyncio.get_event_loop() use_parallel = len(grid) > 100 and get_cpu_count() > 1
points, timing = await loop.run_in_executor( num_workers = get_cpu_count()
None,
self._run_point_loop, if use_parallel:
grid, site, settings, buildings, streets, _clog(f"━━━ PHASE 3: Calculating {len(grid)} points "
spatial_idx, water_bodies, vegetation_areas, f"(PARALLEL, {num_workers} workers) ━━━")
site_elevation, point_elevations
) try:
loop = asyncio.get_event_loop()
result_dicts, timing = await loop.run_in_executor(
None,
calculate_coverage_parallel,
grid, point_elevations,
site.model_dump(), settings.model_dump(),
self.terrain._tile_cache,
buildings, streets, water_bodies, vegetation_areas,
site_elevation, num_workers, _clog,
)
# Convert dicts back to CoveragePoint objects
points = [CoveragePoint(**d) for d in result_dicts]
except Exception as e:
_clog(f"Parallel failed ({e}), falling back to sequential")
use_parallel = False
if not use_parallel:
_clog(f"━━━ PHASE 3: Calculating {len(grid)} points (sequential) ━━━")
loop = asyncio.get_event_loop()
points, timing = await loop.run_in_executor(
None,
self._run_point_loop,
grid, site, settings, buildings, streets,
spatial_idx, water_bodies, vegetation_areas,
site_elevation, point_elevations
)
points_time = time.time() - t_points points_time = time.time() - t_points
total_time = time.time() - calc_start total_time = time.time() - calc_start
@@ -375,13 +406,17 @@ class CoverageService:
_clog(f" Point calc: {points_time:.1f}s " _clog(f" Point calc: {points_time:.1f}s "
f"({points_time/max(1,len(grid))*1000:.1f}ms/point)") f"({points_time/max(1,len(grid))*1000:.1f}ms/point)")
_clog(f" TOTAL: {total_time:.1f}s") _clog(f" TOTAL: {total_time:.1f}s")
_clog(f" Mode: {'parallel (' + str(num_workers) + ' workers)' if use_parallel else 'sequential'}")
_clog(f" Tiles in memory: {len(self.terrain._tile_cache)}") _clog(f" Tiles in memory: {len(self.terrain._tile_cache)}")
if any(v > 0.001 for v in timing.values()): if any(v > 0.001 for v in timing.values()):
_clog("=== PER-STEP BREAKDOWN ===") _clog("=== PER-STEP BREAKDOWN ===")
for step, dt in timing.items(): for step, dt in timing.items():
if dt > 0.001: if dt > 0.001:
_clog(f" {step:20s} {dt:.3f}s " if isinstance(dt, float):
f"({dt/max(1,len(grid))*1000:.2f}ms/point)") _clog(f" {step:20s} {dt:.3f}s "
f"({dt/max(1,len(grid))*1000:.2f}ms/point)")
else:
_clog(f" {step:20s} {dt}")
return points return points

View File

@@ -0,0 +1,250 @@
"""
Parallel coverage calculation using ProcessPoolExecutor.
Workers receive pre-loaded terrain cache, buildings, and OSM data
via a shared pickle file. Each worker initializes module-level
service singletons with the cached data, then processes point chunks.
Usage:
from app.services.parallel_coverage_service import calculate_coverage_parallel
"""
import os
import sys
import time
import pickle
import tempfile
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor
from typing import List, Dict, Tuple, Any, Optional, Callable
import numpy as np
# ── Module-level worker state (set once per process by _init_worker) ──
_worker_data: Dict[str, Any] = {}
_worker_initialized = False
def _init_worker(shared_data_path: str):
"""Initialize a worker process with shared data from temp file.
Injects terrain cache into the module-level terrain_service singleton
so that all other services (LOS, dominant path, etc.) automatically
see the cached tiles.
"""
global _worker_data, _worker_initialized
if _worker_initialized:
return
t0 = time.time()
pid = os.getpid()
# Load shared data
with open(shared_data_path, 'rb') as f:
data = pickle.load(f)
# Inject terrain cache into the global singleton —
# this automatically fixes los_service, dominant_path_service, etc.
# because they hold references to the same terrain_service object.
from app.services.terrain_service import terrain_service
terrain_service._tile_cache = data['terrain_cache']
# Build spatial index from buildings
from app.services.spatial_index import SpatialIndex
spatial_idx = SpatialIndex()
if data['buildings']:
spatial_idx.build(data['buildings'])
_worker_data = {
'buildings': data['buildings'],
'streets': data['streets'],
'water_bodies': data['water_bodies'],
'vegetation_areas': data['vegetation_areas'],
'spatial_idx': spatial_idx,
'site_dict': data['site_dict'],
'settings_dict': data['settings_dict'],
'site_elevation': data['site_elevation'],
}
_worker_initialized = True
dt = time.time() - t0
print(f"[WORKER {pid}] Initialized in {dt:.1f}s — "
f"{len(data['terrain_cache'])} tiles, "
f"{len(data['buildings'])} buildings, "
f"{len(data.get('vegetation_areas', []))} vegetation",
flush=True)
def _process_chunk(chunk: List[Tuple[float, float, float]]) -> List[Dict]:
"""Process a chunk of (lat, lon, point_elevation) tuples.
Returns list of CoveragePoint dicts for points above min_signal.
"""
from app.services.coverage_service import CoverageService, SiteParams, CoverageSettings
data = _worker_data
site = SiteParams(**data['site_dict'])
settings = CoverageSettings(**data['settings_dict'])
svc = CoverageService()
timing = {
"los": 0.0, "buildings": 0.0, "antenna": 0.0,
"dominant_path": 0.0, "street_canyon": 0.0,
"reflection": 0.0, "vegetation": 0.0,
}
results = []
for lat, lon, point_elev in chunk:
point = svc._calculate_point_sync(
site, lat, lon, settings,
data['buildings'], data['streets'],
data['spatial_idx'], data['water_bodies'],
data['vegetation_areas'],
data['site_elevation'], point_elev, timing,
)
if point.rsrp >= settings.min_signal:
results.append(point.model_dump())
return results
# ── Public API ──
def get_cpu_count() -> int:
"""Get number of usable CPU cores, capped at 14."""
try:
return min(mp.cpu_count() or 4, 14)
except Exception:
return 4
def calculate_coverage_parallel(
grid: List[Tuple[float, float]],
point_elevations: Dict[Tuple[float, float], float],
site_dict: Dict,
settings_dict: Dict,
terrain_cache: Dict[str, np.ndarray],
buildings: List,
streets: List,
water_bodies: List,
vegetation_areas: List,
site_elevation: float,
num_workers: Optional[int] = None,
log_fn: Optional[Callable[[str], None]] = None,
) -> Tuple[List[Dict], Dict[str, float]]:
"""Calculate coverage points in parallel using ProcessPoolExecutor.
Args:
grid: List of (lat, lon) tuples.
point_elevations: Pre-computed {(lat, lon): elevation} dict.
site_dict: SiteParams as a dict (for pickling).
settings_dict: CoverageSettings as a dict (for pickling).
terrain_cache: {tile_name: np.ndarray} — pre-loaded SRTM tiles.
buildings, streets, water_bodies, vegetation_areas: OSM data.
site_elevation: Elevation at site location (meters).
num_workers: Override worker count (default: auto-detect).
log_fn: Logging function (receives string messages).
Returns:
(results, timing) where results is list of CoveragePoint dicts.
"""
if log_fn is None:
log_fn = lambda msg: print(f"[PARALLEL] {msg}", flush=True)
if num_workers is None:
num_workers = get_cpu_count()
total_points = len(grid)
log_fn(f"Parallel mode: {total_points} points, {num_workers} workers")
# Prepare items with pre-computed elevations
items = [
(lat, lon, point_elevations.get((lat, lon), 0.0))
for lat, lon in grid
]
# Split into chunks — ~4 chunks per worker for granular progress
chunks_per_worker = 4
chunk_size = max(1, len(items) // (num_workers * chunks_per_worker))
chunks = [items[i:i + chunk_size] for i in range(0, len(items), chunk_size)]
log_fn(f"Split into {len(chunks)} chunks of ~{chunk_size} points")
# ── Serialize shared data to temp file (once, not per-worker) ──
t_serial = time.time()
shared_data = {
'terrain_cache': terrain_cache,
'buildings': buildings,
'streets': streets,
'water_bodies': water_bodies,
'vegetation_areas': vegetation_areas,
'site_dict': site_dict,
'settings_dict': settings_dict,
'site_elevation': site_elevation,
}
tmpfile = tempfile.NamedTemporaryFile(delete=False, suffix='.pkl')
try:
pickle.dump(shared_data, tmpfile, protocol=pickle.HIGHEST_PROTOCOL)
finally:
tmpfile.close()
shared_data_path = tmpfile.name
file_size_mb = os.path.getsize(shared_data_path) / (1024 * 1024)
serial_time = time.time() - t_serial
log_fn(f"Serialized shared data: {file_size_mb:.1f}MB in {serial_time:.1f}s")
# Free main-process memory for the duplicate
del shared_data
# ── Run in process pool ──
t_calc = time.time()
all_results: List[Dict] = []
completed_points = 0
try:
with ProcessPoolExecutor(
max_workers=num_workers,
initializer=_init_worker,
initargs=(shared_data_path,),
) as executor:
futures = [executor.submit(_process_chunk, chunk) for chunk in chunks]
for i, future in enumerate(futures):
try:
chunk_results = future.result(timeout=600) # 10 min max per chunk
all_results.extend(chunk_results)
except Exception as e:
log_fn(f"Chunk {i} failed: {e}")
completed_points += len(chunks[i])
pct = min(100, completed_points * 100 // total_points)
elapsed = time.time() - t_calc
rate = completed_points / elapsed if elapsed > 0 else 0
# Log every ~10% or on last chunk
if (i + 1) % max(1, len(chunks) // 10) == 0 or i == len(chunks) - 1:
eta = (total_points - completed_points) / rate if rate > 0 else 0
log_fn(f"Progress: {completed_points}/{total_points} ({pct}%) — "
f"{rate:.0f} pts/s, ETA {eta:.0f}s")
finally:
# Clean up temp file
try:
os.unlink(shared_data_path)
except Exception:
pass
calc_time = time.time() - t_calc
log_fn(f"Parallel done: {calc_time:.1f}s, {len(all_results)} results "
f"({calc_time / max(1, total_points) * 1000:.1f}ms/point)")
timing = {
"parallel_total": calc_time,
"serialize": serial_time,
"workers": num_workers,
}
return all_results, timing

View File

@@ -1,6 +1,11 @@
"""Entry point for PyInstaller bundle""" """Entry point for PyInstaller bundle"""
import os import os
import sys import sys
import multiprocessing
# Required for ProcessPoolExecutor to work in PyInstaller frozen exe on Windows.
# Must be called before any other multiprocessing usage.
multiprocessing.freeze_support()
# Force unbuffered stdout/stderr — critical for piped output (Electron, bat files) # Force unbuffered stdout/stderr — critical for piped output (Electron, bat files)
os.environ['PYTHONUNBUFFERED'] = '1' os.environ['PYTHONUNBUFFERED'] = '1'

View File

@@ -8,7 +8,9 @@ const store = new Store();
let mainWindow; let mainWindow;
let splashWindow; let splashWindow;
let backendProcess; let backendProcess;
let backendPid = null; // Store PID separately — survives even if backendProcess ref is lost
let backendLogStream; let backendLogStream;
let isQuitting = false;
// ── Paths ────────────────────────────────────────────────────────── // ── Paths ──────────────────────────────────────────────────────────
@@ -184,6 +186,10 @@ async function startBackend() {
}); });
} }
// Store PID immediately
backendPid = backendProcess.pid;
log(`Backend PID: ${backendPid}`);
// Pipe backend output to log // Pipe backend output to log
const backendLogFile = path.join(logDir, 'rfcp-backend.log'); const backendLogFile = path.join(logDir, 'rfcp-backend.log');
const backendLog = fs.createWriteStream(backendLogFile, { flags: 'w' }); const backendLog = fs.createWriteStream(backendLogFile, { flags: 'w' });
@@ -262,10 +268,15 @@ function createMainWindow() {
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
}); });
// Save window state on close // Save window state on close and trigger shutdown
mainWindow.on('close', () => { mainWindow.on('close', () => {
const bounds = mainWindow.getBounds(); try {
store.set('windowState', bounds); const bounds = mainWindow.getBounds();
store.set('windowState', bounds);
} catch (_e) {}
log('Main window closing — killing backend');
isQuitting = true;
killBackend();
}); });
// Load frontend // Load frontend
@@ -309,28 +320,33 @@ function createMainWindow() {
// ── Backend cleanup ─────────────────────────────────────────────── // ── Backend cleanup ───────────────────────────────────────────────
function killBackend() { function killBackend() {
if (!backendProcess) return; const pid = backendPid || backendProcess?.pid;
if (!pid) return;
const pid = backendProcess.pid;
log(`Killing backend (PID ${pid})...`); log(`Killing backend (PID ${pid})...`);
try { try {
if (process.platform === 'win32') { if (process.platform === 'win32') {
// Windows: taskkill with /T (tree) to kill child processes too // Windows: taskkill with /F (force) /T (tree kills child processes too)
execSync(`taskkill /f /t /pid ${pid}`, { stdio: 'ignore' }); execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
} else { } else {
// Unix: kill process group // Unix: kill process group
process.kill(-pid, 'SIGTERM'); try {
process.kill(-pid, 'SIGTERM');
} catch (_e) {
process.kill(pid, 'SIGTERM');
}
} }
} catch (e) { } catch (e) {
// Fallback: try normal kill // Fallback: try normal kill via process handle
try { try {
backendProcess.kill('SIGKILL'); backendProcess?.kill('SIGKILL');
} catch (_e2) { } catch (_e2) {
// Already dead // Already dead — that's fine
} }
} }
backendPid = null;
backendProcess = null; backendProcess = null;
log('Backend killed'); log('Backend killed');
} }
@@ -365,6 +381,8 @@ app.whenReady().then(async () => {
}); });
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
log('Event: window-all-closed');
isQuitting = true;
killBackend(); killBackend();
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
@@ -379,14 +397,36 @@ app.on('activate', () => {
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
log('Event: before-quit');
isQuitting = true;
killBackend();
});
app.on('will-quit', () => {
log('Event: will-quit');
killBackend(); killBackend();
if (backendLogStream) { if (backendLogStream) {
backendLogStream.end(); try { backendLogStream.end(); } catch (_e) {}
backendLogStream = null; backendLogStream = null;
} }
}); });
// Last resort: ensure backend is killed when Node process exits
process.on('exit', () => {
if (backendPid) {
try {
if (process.platform === 'win32') {
execSync(`taskkill /F /T /PID ${backendPid}`, { stdio: 'ignore' });
} else {
process.kill(backendPid, 'SIGKILL');
}
} catch (_e) {
// Best effort
}
}
});
// ── IPC Handlers ─────────────────────────────────────────────────── // ── IPC Handlers ───────────────────────────────────────────────────
ipcMain.handle('get-data-path', () => getDataPath()); ipcMain.handle('get-data-path', () => getDataPath());

View File

@@ -1 +1 @@
{"detail":"Calculation timeout (5 min) — try smaller radius or lower resolution"} {"points":[],"count":0,"settings":{"radius":5000.0,"resolution":300.0,"min_signal":-120.0,"use_terrain":true,"use_buildings":true,"use_materials":true,"use_dominant_path":true,"use_street_canyon":false,"use_reflections":false,"use_water_reflection":false,"use_vegetation":true,"season":"summer","rain_rate":0.0,"indoor_loss_type":"none","use_atmospheric":false,"temperature_c":15.0,"humidity_percent":50.0,"preset":"detailed"},"stats":{"min_rsrp":0,"max_rsrp":0,"avg_rsrp":0,"los_percentage":0,"points_with_buildings":0,"points_with_terrain_loss":0,"points_with_reflection_gain":0,"points_with_vegetation_loss":0,"points_with_rain_loss":0,"points_with_indoor_loss":0,"points_with_atmospheric_loss":0},"computation_time":38.69,"models_used":["okumura_hata","terrain_los","buildings","materials","dominant_path","vegetation"]}

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,12 @@ curl -s %API%/api/health
echo. echo.
echo. echo.
:: Test 2b: System info (CPU cores, parallel mode)
echo [TEST 2b] System info:
curl -s %API%/api/system/info
echo.
echo.
:: Test 3: Coverage - Fast preset, 1 site, 2km radius, 500m resolution (small/quick) :: Test 3: Coverage - Fast preset, 1 site, 2km radius, 500m resolution (small/quick)
echo [TEST 3] Coverage calculation (Fast preset, 2km radius, 500m res)... echo [TEST 3] Coverage calculation (Fast preset, 2km radius, 500m res)...
echo This should complete in a few seconds. echo This should complete in a few seconds.