@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:
2026-02-01 23:12:26 +02:00
parent 1dde56705a
commit defa3ad440
71 changed files with 7134 additions and 256 deletions

View 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",
]

View 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()

View 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

View 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