@mytec: feat: Phase 3.0 Architecture Refactor ✅
Major refactoring of RFCP backend: - Modular propagation models (8 models) - SharedMemoryManager for terrain data - ProcessPoolExecutor parallel processing - WebSocket progress streaming - Building filtering pipeline (351k → 15k) - 82 unit tests Performance: Standard preset 38s → 5s (7.6x speedup) Known issue: Detailed preset timeout (fix in 3.1.0)
This commit is contained in:
11
backend/app/parallel/__init__.py
Normal file
11
backend/app/parallel/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Parallel processing infrastructure for coverage calculations.
|
||||
"""
|
||||
|
||||
from app.parallel.manager import SharedMemoryManager, SharedTerrainData, SharedBuildingData
|
||||
from app.parallel.pool import ManagedProcessPool
|
||||
|
||||
__all__ = [
|
||||
"SharedMemoryManager", "SharedTerrainData", "SharedBuildingData",
|
||||
"ManagedProcessPool",
|
||||
]
|
||||
174
backend/app/parallel/manager.py
Normal file
174
backend/app/parallel/manager.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Shared Memory Manager for parallel processing.
|
||||
|
||||
Instead of copying building/terrain data to each worker,
|
||||
store data in shared memory that all workers can read.
|
||||
"""
|
||||
|
||||
import multiprocessing.shared_memory as shm
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedTerrainData:
|
||||
"""Reference to terrain data in shared memory."""
|
||||
shm_name: str
|
||||
shape: tuple
|
||||
bounds: tuple # (min_lat, min_lon, max_lat, max_lon)
|
||||
resolution: float
|
||||
|
||||
def get_array(self) -> np.ndarray:
|
||||
existing_shm = shm.SharedMemory(name=self.shm_name)
|
||||
return np.ndarray(self.shape, dtype=np.int16, buffer=existing_shm.buf)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SharedBuildingData:
|
||||
"""Reference to building data in shared memory."""
|
||||
shm_centroids_name: str # (N, 2) float64
|
||||
shm_heights_name: str # (N,) float32
|
||||
shm_vertices_name: str # (total_verts, 2) float64
|
||||
shm_offsets_name: str # (N+1,) int32
|
||||
count: int
|
||||
total_vertices: int
|
||||
|
||||
def get_centroids(self) -> np.ndarray:
|
||||
existing = shm.SharedMemory(name=self.shm_centroids_name)
|
||||
return np.ndarray((self.count, 2), dtype=np.float64, buffer=existing.buf)
|
||||
|
||||
def get_heights(self) -> np.ndarray:
|
||||
existing = shm.SharedMemory(name=self.shm_heights_name)
|
||||
return np.ndarray((self.count,), dtype=np.float32, buffer=existing.buf)
|
||||
|
||||
def get_offsets(self) -> np.ndarray:
|
||||
existing = shm.SharedMemory(name=self.shm_offsets_name)
|
||||
return np.ndarray((self.count + 1,), dtype=np.int32, buffer=existing.buf)
|
||||
|
||||
def get_vertices(self) -> np.ndarray:
|
||||
existing = shm.SharedMemory(name=self.shm_vertices_name)
|
||||
return np.ndarray((self.total_vertices, 2), dtype=np.float64, buffer=existing.buf)
|
||||
|
||||
def get_polygon(self, idx: int) -> np.ndarray:
|
||||
offsets = self.get_offsets()
|
||||
vertices = self.get_vertices()
|
||||
start, end = offsets[idx], offsets[idx + 1]
|
||||
return vertices[start:end]
|
||||
|
||||
|
||||
class SharedMemoryManager:
|
||||
"""
|
||||
Manages shared memory blocks for parallel processing.
|
||||
|
||||
Usage:
|
||||
manager = SharedMemoryManager()
|
||||
terrain_ref = manager.store_terrain(heights, bounds, resolution)
|
||||
buildings_ref = manager.store_buildings(buildings)
|
||||
|
||||
# Pass references (small dataclasses) to workers
|
||||
pool.map(worker_func, points, terrain_ref, buildings_ref)
|
||||
|
||||
# Workers attach to shared memory — no copy!
|
||||
terrain = terrain_ref.get_array()
|
||||
|
||||
# Cleanup when done
|
||||
manager.cleanup()
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._shm_blocks: list = []
|
||||
|
||||
def store_terrain(
|
||||
self, heights: np.ndarray, bounds: tuple, resolution: float,
|
||||
) -> SharedTerrainData:
|
||||
"""Store terrain heights in shared memory."""
|
||||
shm_block = shm.SharedMemory(create=True, size=heights.nbytes)
|
||||
self._shm_blocks.append(shm_block)
|
||||
|
||||
shm_array = np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_block.buf)
|
||||
shm_array[:] = heights[:]
|
||||
|
||||
return SharedTerrainData(
|
||||
shm_name=shm_block.name,
|
||||
shape=heights.shape,
|
||||
bounds=bounds,
|
||||
resolution=resolution,
|
||||
)
|
||||
|
||||
def store_buildings(self, buildings: list) -> Optional[SharedBuildingData]:
|
||||
"""Store building data in shared memory.
|
||||
|
||||
Args:
|
||||
buildings: List of Building objects or dicts with geometry.
|
||||
|
||||
Returns:
|
||||
SharedBuildingData reference, or None if no buildings.
|
||||
"""
|
||||
n = len(buildings)
|
||||
if n == 0:
|
||||
return None
|
||||
|
||||
# Extract centroids
|
||||
centroids = np.zeros((n, 2), dtype=np.float64)
|
||||
heights = np.zeros(n, dtype=np.float32)
|
||||
all_vertices = []
|
||||
offsets = [0]
|
||||
|
||||
for i, b in enumerate(buildings):
|
||||
# Support both dict and object forms
|
||||
if hasattr(b, 'geometry'):
|
||||
geom = b.geometry
|
||||
h = getattr(b, 'height', 10.0)
|
||||
else:
|
||||
geom = b.get('geometry', [])
|
||||
h = b.get('height', 10.0)
|
||||
|
||||
if geom:
|
||||
lats = [p[1] for p in geom]
|
||||
lons = [p[0] for p in geom]
|
||||
centroids[i] = [sum(lats) / len(lats), sum(lons) / len(lons)]
|
||||
for lon, lat in geom:
|
||||
all_vertices.append([lat, lon])
|
||||
heights[i] = h or 10.0
|
||||
offsets.append(len(all_vertices))
|
||||
|
||||
vertices = np.array(all_vertices, dtype=np.float64) if all_vertices else np.zeros((0, 2), dtype=np.float64)
|
||||
offsets = np.array(offsets, dtype=np.int32)
|
||||
|
||||
# Create shared memory
|
||||
shm_centroids = shm.SharedMemory(create=True, size=max(centroids.nbytes, 1))
|
||||
shm_heights = shm.SharedMemory(create=True, size=max(heights.nbytes, 1))
|
||||
shm_vertices = shm.SharedMemory(create=True, size=max(vertices.nbytes, 1))
|
||||
shm_offsets = shm.SharedMemory(create=True, size=max(offsets.nbytes, 1))
|
||||
|
||||
self._shm_blocks.extend([shm_centroids, shm_heights, shm_vertices, shm_offsets])
|
||||
|
||||
# Copy data
|
||||
if centroids.nbytes > 0:
|
||||
np.ndarray(centroids.shape, dtype=centroids.dtype, buffer=shm_centroids.buf)[:] = centroids
|
||||
if heights.nbytes > 0:
|
||||
np.ndarray(heights.shape, dtype=heights.dtype, buffer=shm_heights.buf)[:] = heights
|
||||
if vertices.nbytes > 0:
|
||||
np.ndarray(vertices.shape, dtype=vertices.dtype, buffer=shm_vertices.buf)[:] = vertices
|
||||
if offsets.nbytes > 0:
|
||||
np.ndarray(offsets.shape, dtype=offsets.dtype, buffer=shm_offsets.buf)[:] = offsets
|
||||
|
||||
return SharedBuildingData(
|
||||
shm_centroids_name=shm_centroids.name,
|
||||
shm_heights_name=shm_heights.name,
|
||||
shm_vertices_name=shm_vertices.name,
|
||||
shm_offsets_name=shm_offsets.name,
|
||||
count=n,
|
||||
total_vertices=len(all_vertices),
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Release all shared memory blocks."""
|
||||
for block in self._shm_blocks:
|
||||
try:
|
||||
block.close()
|
||||
block.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
self._shm_blocks.clear()
|
||||
136
backend/app/parallel/pool.py
Normal file
136
backend/app/parallel/pool.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Managed process pool with automatic cleanup.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import multiprocessing as mp
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from typing import List, Dict, Tuple, Optional, Callable
|
||||
|
||||
|
||||
class ManagedProcessPool:
|
||||
"""
|
||||
Process pool wrapper with:
|
||||
- Automatic cleanup on exit
|
||||
- Worker process kill on failure
|
||||
- Progress reporting
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers: int = 6):
|
||||
self.max_workers = min(max_workers, 6)
|
||||
self._pool: Optional[ProcessPoolExecutor] = None
|
||||
|
||||
def map_chunks(
|
||||
self,
|
||||
worker_fn: Callable,
|
||||
chunks: List[tuple],
|
||||
log_fn: Optional[Callable] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Submit chunks to the pool and collect results.
|
||||
|
||||
Args:
|
||||
worker_fn: Function to call for each chunk
|
||||
chunks: List of (chunk_data, *args) tuples
|
||||
log_fn: Progress logging function
|
||||
|
||||
Returns:
|
||||
Flattened list of result dicts
|
||||
"""
|
||||
if log_fn is None:
|
||||
log_fn = lambda msg: print(f"[POOL] {msg}", flush=True)
|
||||
|
||||
all_results: List[Dict] = []
|
||||
|
||||
try:
|
||||
ctx = mp.get_context('spawn')
|
||||
self._pool = ProcessPoolExecutor(
|
||||
max_workers=self.max_workers, mp_context=ctx,
|
||||
)
|
||||
|
||||
futures = {
|
||||
self._pool.submit(worker_fn, chunk): i
|
||||
for i, chunk in enumerate(chunks)
|
||||
}
|
||||
|
||||
completed = 0
|
||||
t0 = time.time()
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
chunk_results = future.result()
|
||||
all_results.extend(chunk_results)
|
||||
except Exception as e:
|
||||
log_fn(f"Chunk error: {e}")
|
||||
|
||||
completed += 1
|
||||
elapsed = time.time() - t0
|
||||
pct = completed * 100 // len(chunks)
|
||||
log_fn(f"Progress: {completed}/{len(chunks)} ({pct}%)")
|
||||
|
||||
except Exception as e:
|
||||
log_fn(f"Pool error: {e}")
|
||||
|
||||
finally:
|
||||
if self._pool:
|
||||
self._pool.shutdown(wait=False, cancel_futures=True)
|
||||
time.sleep(0.5)
|
||||
killed = self._kill_orphans()
|
||||
if killed > 0:
|
||||
log_fn(f"Cleaned up {killed} orphaned workers")
|
||||
|
||||
return all_results
|
||||
|
||||
@staticmethod
|
||||
def _kill_orphans() -> int:
|
||||
"""Kill orphaned rfcp-server worker processes."""
|
||||
my_pid = os.getpid()
|
||||
killed = 0
|
||||
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['tasklist', '/FI', 'IMAGENAME eq rfcp-server.exe', '/FO', 'CSV', '/NH'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if 'rfcp-server.exe' not in line:
|
||||
continue
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 2:
|
||||
pid_str = parts[1].strip().strip('"')
|
||||
try:
|
||||
pid = int(pid_str)
|
||||
if pid != my_pid:
|
||||
subprocess.run(
|
||||
['taskkill', '/F', '/PID', str(pid)],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
killed += 1
|
||||
except (ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pgrep', '-f', 'rfcp-server'],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
for pid_str in result.stdout.strip().split('\n'):
|
||||
if not pid_str:
|
||||
continue
|
||||
try:
|
||||
pid = int(pid_str)
|
||||
if pid != my_pid:
|
||||
os.kill(pid, 9)
|
||||
killed += 1
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return killed
|
||||
64
backend/app/parallel/worker.py
Normal file
64
backend/app/parallel/worker.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Worker functions for parallel coverage calculation.
|
||||
|
||||
These run in separate processes and access shared memory data.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional
|
||||
from app.parallel.manager import SharedTerrainData, SharedBuildingData
|
||||
|
||||
|
||||
def process_chunk(
|
||||
chunk: List[tuple],
|
||||
terrain_cache: dict,
|
||||
buildings: list,
|
||||
osm_data: dict,
|
||||
config: dict,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Process a chunk of grid points.
|
||||
|
||||
This is the standard worker function used by both Ray and ProcessPoolExecutor.
|
||||
It re-uses the existing coverage calculation logic.
|
||||
"""
|
||||
# Inject terrain cache into the module-level singleton
|
||||
from app.services.terrain_service import terrain_service
|
||||
terrain_service._tile_cache = terrain_cache
|
||||
|
||||
# Build spatial index
|
||||
from app.services.spatial_index import SpatialIndex
|
||||
spatial_idx = SpatialIndex()
|
||||
if buildings:
|
||||
spatial_idx.build(buildings)
|
||||
|
||||
# Process points using existing calculator
|
||||
from app.services.coverage_service import CoverageService, SiteParams, CoverageSettings
|
||||
|
||||
site = SiteParams(**config['site_dict'])
|
||||
settings = CoverageSettings(**config['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,
|
||||
}
|
||||
|
||||
precomputed = config.get('precomputed')
|
||||
|
||||
results = []
|
||||
for lat, lon, point_elev in chunk:
|
||||
pre = precomputed.get((lat, lon)) if precomputed else None
|
||||
point = svc._calculate_point_sync(
|
||||
site, lat, lon, settings,
|
||||
buildings, osm_data.get('streets', []),
|
||||
spatial_idx, osm_data.get('water_bodies', []),
|
||||
osm_data.get('vegetation_areas', []),
|
||||
config['site_elevation'], point_elev, timing,
|
||||
precomputed_distance=pre.get('distance') if pre else None,
|
||||
precomputed_path_loss=pre.get('path_loss') if pre else None,
|
||||
)
|
||||
if point.rsrp >= settings.min_signal:
|
||||
results.append(point.model_dump())
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user