@mytec: iter2.4 ready for testing
This commit is contained in:
@@ -12,6 +12,7 @@ from app.services.coverage_service import (
|
||||
apply_preset,
|
||||
PRESETS,
|
||||
)
|
||||
from app.services.parallel_coverage_service import CancellationToken
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -59,6 +60,7 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
||||
|
||||
# Time the calculation
|
||||
start_time = time.time()
|
||||
cancel_token = CancellationToken()
|
||||
|
||||
try:
|
||||
# Calculate with 5-minute timeout
|
||||
@@ -66,7 +68,8 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
||||
points = await asyncio.wait_for(
|
||||
coverage_service.calculate_coverage(
|
||||
request.sites[0],
|
||||
request.settings
|
||||
request.settings,
|
||||
cancel_token,
|
||||
),
|
||||
timeout=300.0
|
||||
)
|
||||
@@ -74,12 +77,17 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
||||
points = await asyncio.wait_for(
|
||||
coverage_service.calculate_multi_site_coverage(
|
||||
request.sites,
|
||||
request.settings
|
||||
request.settings,
|
||||
cancel_token,
|
||||
),
|
||||
timeout=300.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
cancel_token.cancel()
|
||||
raise HTTPException(408, "Calculation timeout (5 min) — try smaller radius or lower resolution")
|
||||
except asyncio.CancelledError:
|
||||
cancel_token.cancel()
|
||||
raise HTTPException(499, "Client disconnected")
|
||||
|
||||
computation_time = time.time() - start_time
|
||||
|
||||
|
||||
@@ -21,25 +21,24 @@ async def get_system_info():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check GPU
|
||||
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
|
||||
# Check GPU via gpu_service
|
||||
from app.services.gpu_service import gpu_service
|
||||
gpu_info = gpu_service.get_info()
|
||||
|
||||
# Determine parallel backend
|
||||
if ray_available:
|
||||
parallel_backend = "ray"
|
||||
elif cpu_cores > 1:
|
||||
parallel_backend = "process_pool"
|
||||
else:
|
||||
parallel_backend = "sequential"
|
||||
|
||||
return {
|
||||
"cpu_cores": cpu_cores,
|
||||
"parallel_workers": min(cpu_cores, 14),
|
||||
"parallel_backend": "ray" if ray_available else "sequential",
|
||||
"parallel_backend": parallel_backend,
|
||||
"ray_available": ray_available,
|
||||
"ray_initialized": ray_initialized,
|
||||
"gpu": gpu_info,
|
||||
"gpu_enabled": gpu_info is not None,
|
||||
"gpu_available": gpu_info.get("available", False),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
@@ -11,6 +13,46 @@ from app.services.los_service import los_service
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _build_elevation_grid(min_lat, max_lat, min_lon, max_lon, resolution):
|
||||
"""Build a 2D elevation grid. Runs in thread executor (CPU-bound)."""
|
||||
import numpy as np
|
||||
|
||||
rows = min(resolution, 200)
|
||||
cols = min(resolution, 200)
|
||||
|
||||
lats = np.linspace(max_lat, min_lat, rows) # north to south
|
||||
lons = np.linspace(min_lon, max_lon, cols)
|
||||
|
||||
grid = []
|
||||
min_elev = float('inf')
|
||||
max_elev = float('-inf')
|
||||
|
||||
for lat in lats:
|
||||
row = []
|
||||
for lon in lons:
|
||||
elev = terrain_service.get_elevation_sync(float(lat), float(lon))
|
||||
row.append(elev)
|
||||
if elev < min_elev:
|
||||
min_elev = elev
|
||||
if elev > max_elev:
|
||||
max_elev = elev
|
||||
grid.append(row)
|
||||
|
||||
return {
|
||||
"grid": grid,
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"min_elevation": min_elev if min_elev != float('inf') else 0,
|
||||
"max_elevation": max_elev if max_elev != float('-inf') else 0,
|
||||
"bbox": {
|
||||
"min_lat": min_lat,
|
||||
"max_lat": max_lat,
|
||||
"min_lon": min_lon,
|
||||
"max_lon": max_lon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/elevation")
|
||||
async def get_elevation(
|
||||
lat: float = Query(..., ge=-90, le=90, description="Latitude"),
|
||||
@@ -26,6 +68,42 @@ async def get_elevation(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/elevation-grid")
|
||||
async def get_elevation_grid(
|
||||
min_lat: float = Query(..., ge=-90, le=90, description="South boundary"),
|
||||
max_lat: float = Query(..., ge=-90, le=90, description="North boundary"),
|
||||
min_lon: float = Query(..., ge=-180, le=180, description="West boundary"),
|
||||
max_lon: float = Query(..., ge=-180, le=180, description="East boundary"),
|
||||
resolution: int = Query(100, ge=10, le=200, description="Grid size (rows/cols)"),
|
||||
):
|
||||
"""Get elevation grid for a bounding box. Returns a 2D array for terrain visualization."""
|
||||
if max_lat <= min_lat or max_lon <= min_lon:
|
||||
raise HTTPException(400, "Invalid bbox: max must be greater than min")
|
||||
if (max_lat - min_lat) > 2.0 or (max_lon - min_lon) > 2.0:
|
||||
raise HTTPException(400, "Bbox too large (max 2 degrees per axis)")
|
||||
|
||||
# Ensure terrain tiles are loaded for this area
|
||||
await terrain_service.ensure_tiles_for_bbox(min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
# Pre-load all tiles that cover the bbox
|
||||
lat_start = int(math.floor(min_lat))
|
||||
lat_end = int(math.floor(max_lat))
|
||||
lon_start = int(math.floor(min_lon))
|
||||
lon_end = int(math.floor(max_lon))
|
||||
for lat_i in range(lat_start, lat_end + 1):
|
||||
for lon_i in range(lon_start, lon_end + 1):
|
||||
tile_name = terrain_service.get_tile_name(lat_i + 0.5, lon_i + 0.5)
|
||||
terrain_service._load_tile(tile_name)
|
||||
|
||||
# Build grid in thread executor (CPU-bound sync calls)
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None, _build_elevation_grid,
|
||||
min_lat, max_lat, min_lon, max_lon, resolution,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_elevation_profile(
|
||||
lat1: float = Query(..., description="Start latitude"),
|
||||
@@ -87,9 +165,9 @@ async def check_fresnel_clearance(
|
||||
@router.get("/tiles")
|
||||
async def list_cached_tiles():
|
||||
"""List cached SRTM tiles"""
|
||||
tiles = list(terrain_service.cache_dir.glob("*.hgt"))
|
||||
tiles = list(terrain_service.terrain_path.glob("*.hgt"))
|
||||
return {
|
||||
"cache_dir": str(terrain_service.cache_dir),
|
||||
"cache_dir": str(terrain_service.terrain_path),
|
||||
"tiles": [t.stem for t in tiles],
|
||||
"count": len(tiles)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ from app.services.indoor_service import indoor_service
|
||||
from app.services.atmospheric_service import atmospheric_service
|
||||
from app.services.parallel_coverage_service import (
|
||||
calculate_coverage_parallel, get_cpu_count, get_parallel_backend,
|
||||
CancellationToken,
|
||||
)
|
||||
|
||||
|
||||
@@ -280,7 +281,8 @@ class CoverageService:
|
||||
async def calculate_coverage(
|
||||
self,
|
||||
site: SiteParams,
|
||||
settings: CoverageSettings
|
||||
settings: CoverageSettings,
|
||||
cancel_token: Optional[CancellationToken] = None,
|
||||
) -> List[CoveragePoint]:
|
||||
"""
|
||||
Calculate coverage grid for a single site
|
||||
@@ -352,6 +354,32 @@ class CoverageService:
|
||||
f"pre-computed {len(grid)} elevations")
|
||||
_clog(f"━━━ PHASE 2 done: {terrain_time:.1f}s ━━━")
|
||||
|
||||
# ━━━ PHASE 2.5: Vectorized pre-computation (GPU/NumPy) ━━━
|
||||
from app.services.gpu_service import gpu_service
|
||||
|
||||
t_gpu = time.time()
|
||||
grid_lats = np.array([lat for lat, lon in grid])
|
||||
grid_lons = np.array([lon for lat, lon in grid])
|
||||
|
||||
pre_distances = gpu_service.precompute_distances(
|
||||
grid_lats, grid_lons, site.lat, site.lon
|
||||
)
|
||||
pre_path_loss = gpu_service.precompute_path_loss(
|
||||
pre_distances, site.frequency, site.height
|
||||
)
|
||||
|
||||
# Build lookup dict for point loop
|
||||
precomputed = {}
|
||||
for i, (lat, lon) in enumerate(grid):
|
||||
precomputed[(lat, lon)] = {
|
||||
'distance': float(pre_distances[i]),
|
||||
'path_loss': float(pre_path_loss[i]),
|
||||
}
|
||||
|
||||
gpu_time = time.time() - t_gpu
|
||||
_clog(f"━━━ PHASE 2.5: Vectorized pre-computation done: {gpu_time:.3f}s "
|
||||
f"({len(grid)} points, backend={'GPU' if gpu_service.available else 'CPU/NumPy'}) ━━━")
|
||||
|
||||
# ━━━ PHASE 3: Point calculation ━━━
|
||||
dominant_path_service._log_count = 0 # Reset diagnostic counter
|
||||
t_points = time.time()
|
||||
@@ -368,12 +396,15 @@ class CoverageService:
|
||||
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,
|
||||
lambda: 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,
|
||||
cancel_token=cancel_token,
|
||||
precomputed=precomputed,
|
||||
),
|
||||
)
|
||||
|
||||
# Convert dicts back to CoveragePoint objects
|
||||
@@ -389,10 +420,13 @@ class CoverageService:
|
||||
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
|
||||
lambda: self._run_point_loop(
|
||||
grid, site, settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas,
|
||||
site_elevation, point_elevations,
|
||||
cancel_token=cancel_token,
|
||||
precomputed=precomputed,
|
||||
),
|
||||
)
|
||||
|
||||
points_time = time.time() - t_points
|
||||
@@ -423,7 +457,8 @@ class CoverageService:
|
||||
async def calculate_multi_site_coverage(
|
||||
self,
|
||||
sites: List[SiteParams],
|
||||
settings: CoverageSettings
|
||||
settings: CoverageSettings,
|
||||
cancel_token: Optional[CancellationToken] = None,
|
||||
) -> List[CoveragePoint]:
|
||||
"""
|
||||
Calculate combined coverage from multiple sites
|
||||
@@ -437,7 +472,7 @@ class CoverageService:
|
||||
|
||||
# Get all individual coverages
|
||||
all_coverages = await asyncio.gather(*[
|
||||
self.calculate_coverage(site, settings)
|
||||
self.calculate_coverage(site, settings, cancel_token)
|
||||
for site in sites
|
||||
])
|
||||
|
||||
@@ -485,7 +520,8 @@ class CoverageService:
|
||||
def _run_point_loop(
|
||||
self, grid, site, settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas,
|
||||
site_elevation, point_elevations
|
||||
site_elevation, point_elevations,
|
||||
cancel_token=None, precomputed=None,
|
||||
):
|
||||
"""Sync point loop - runs in ThreadPoolExecutor, bypasses event loop."""
|
||||
points = []
|
||||
@@ -496,14 +532,22 @@ class CoverageService:
|
||||
log_interval = max(1, total // 20)
|
||||
|
||||
for i, (lat, lon) in enumerate(grid):
|
||||
if cancel_token and cancel_token.is_cancelled:
|
||||
_clog(f"Cancelled at {i}/{total}")
|
||||
break
|
||||
|
||||
if i % log_interval == 0:
|
||||
_clog(f"Progress: {i}/{total} ({i*100//total}%)")
|
||||
|
||||
pre = precomputed.get((lat, lon)) if precomputed else None
|
||||
|
||||
point = self._calculate_point_sync(
|
||||
site, lat, lon, settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas,
|
||||
site_elevation, point_elevations.get((lat, lon), 0.0),
|
||||
timing
|
||||
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:
|
||||
points.append(point)
|
||||
@@ -523,17 +567,25 @@ class CoverageService:
|
||||
vegetation_areas: List[VegetationArea],
|
||||
site_elevation: float,
|
||||
point_elevation: float,
|
||||
timing: dict
|
||||
timing: dict,
|
||||
precomputed_distance: Optional[float] = None,
|
||||
precomputed_path_loss: Optional[float] = None,
|
||||
) -> CoveragePoint:
|
||||
"""Fully synchronous point calculation. All terrain tiles must be pre-loaded."""
|
||||
|
||||
# Distance
|
||||
distance = TerrainService.haversine_distance(site.lat, site.lon, lat, lon)
|
||||
# Distance (use precomputed if available)
|
||||
if precomputed_distance is not None:
|
||||
distance = precomputed_distance
|
||||
else:
|
||||
distance = TerrainService.haversine_distance(site.lat, site.lon, lat, lon)
|
||||
if distance < 1:
|
||||
distance = 1
|
||||
|
||||
# Base path loss
|
||||
path_loss = self._okumura_hata(distance, site.frequency, site.height, 1.5)
|
||||
# Base path loss (use precomputed if available)
|
||||
if precomputed_path_loss is not None:
|
||||
path_loss = precomputed_path_loss
|
||||
else:
|
||||
path_loss = self._okumura_hata(distance, site.frequency, site.height, 1.5)
|
||||
|
||||
# Antenna pattern
|
||||
antenna_loss = 0.0
|
||||
|
||||
119
backend/app/services/gpu_service.py
Normal file
119
backend/app/services/gpu_service.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
GPU-accelerated computation service using CuPy.
|
||||
Falls back to NumPy when CuPy/CUDA is not available.
|
||||
|
||||
Provides vectorized batch operations for coverage calculation:
|
||||
- Haversine distance (site → all grid points)
|
||||
- Okumura-Hata path loss (all distances at once)
|
||||
|
||||
Usage:
|
||||
from app.services.gpu_service import gpu_service, GPU_AVAILABLE
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
# ── Try CuPy import ──
|
||||
|
||||
GPU_AVAILABLE = False
|
||||
GPU_INFO: Optional[Dict[str, Any]] = None
|
||||
cp = None
|
||||
|
||||
try:
|
||||
import cupy as _cp
|
||||
if _cp.cuda.runtime.getDeviceCount() > 0:
|
||||
cp = _cp
|
||||
GPU_AVAILABLE = True
|
||||
props = _cp.cuda.runtime.getDeviceProperties(0)
|
||||
GPU_INFO = {
|
||||
"name": props["name"].decode() if isinstance(props["name"], bytes) else str(props["name"]),
|
||||
"memory_mb": props["totalGlobalMem"] // (1024 * 1024),
|
||||
"cuda_version": _cp.cuda.runtime.runtimeGetVersion(),
|
||||
}
|
||||
print(f"[GPU] CUDA available: {GPU_INFO['name']} ({GPU_INFO['memory_mb']} MB)", flush=True)
|
||||
except ImportError:
|
||||
print("[GPU] CuPy not installed — using CPU/NumPy", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[GPU] CUDA check failed: {e} — using CPU/NumPy", flush=True)
|
||||
|
||||
|
||||
# Array module: cupy on GPU, numpy on CPU
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
|
||||
|
||||
def _to_cpu(arr):
|
||||
"""Transfer array to CPU numpy if on GPU."""
|
||||
if GPU_AVAILABLE and hasattr(arr, 'get'):
|
||||
return arr.get()
|
||||
return np.asarray(arr)
|
||||
|
||||
|
||||
class GPUService:
|
||||
"""GPU-accelerated batch operations for coverage calculation."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return GPU_AVAILABLE
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""Return GPU info dict for system endpoint."""
|
||||
if not GPU_AVAILABLE:
|
||||
return {"available": False, "name": None, "memory_mb": None}
|
||||
return {"available": True, **GPU_INFO}
|
||||
|
||||
def precompute_distances(
|
||||
self,
|
||||
grid_lats: np.ndarray,
|
||||
grid_lons: np.ndarray,
|
||||
site_lat: float,
|
||||
site_lon: float,
|
||||
) -> np.ndarray:
|
||||
"""Vectorized haversine distance from site to all grid points.
|
||||
|
||||
Returns distances in meters as a CPU numpy array.
|
||||
"""
|
||||
lat1 = xp.radians(xp.asarray(grid_lats, dtype=xp.float64))
|
||||
lon1 = xp.radians(xp.asarray(grid_lons, dtype=xp.float64))
|
||||
lat2 = xp.radians(xp.float64(site_lat))
|
||||
lon2 = xp.radians(xp.float64(site_lon))
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = xp.sin(dlat / 2) ** 2 + xp.cos(lat1) * xp.cos(lat2) * xp.sin(dlon / 2) ** 2
|
||||
c = 2 * xp.arcsin(xp.sqrt(a))
|
||||
|
||||
distances = 6371000.0 * c
|
||||
return _to_cpu(distances)
|
||||
|
||||
def precompute_path_loss(
|
||||
self,
|
||||
distances: np.ndarray,
|
||||
frequency_mhz: float,
|
||||
tx_height: float,
|
||||
rx_height: float = 1.5,
|
||||
) -> np.ndarray:
|
||||
"""Vectorized Okumura-Hata path loss for all distances.
|
||||
|
||||
Returns path loss in dB as a CPU numpy array.
|
||||
"""
|
||||
d_arr = xp.asarray(distances, dtype=xp.float64)
|
||||
d_km = xp.maximum(d_arr / 1000.0, 0.1)
|
||||
|
||||
freq = float(frequency_mhz)
|
||||
h_tx = float(tx_height)
|
||||
h_rx = float(rx_height)
|
||||
|
||||
log_f = xp.log10(xp.float64(freq))
|
||||
log_hb = xp.log10(xp.float64(h_tx))
|
||||
|
||||
a_hm = (1.1 * log_f - 0.7) * h_rx - (1.56 * log_f - 0.8)
|
||||
|
||||
L = (69.55 + 26.16 * log_f - 13.82 * log_hb - a_hm
|
||||
+ (44.9 - 6.55 * log_hb) * xp.log10(d_km))
|
||||
|
||||
return _to_cpu(L)
|
||||
|
||||
|
||||
# Singleton
|
||||
gpu_service = GPUService()
|
||||
@@ -24,11 +24,28 @@ Usage:
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import multiprocessing as mp
|
||||
from typing import List, Dict, Tuple, Any, Optional, Callable
|
||||
import numpy as np
|
||||
|
||||
|
||||
# ── Cancellation token ──
|
||||
|
||||
class CancellationToken:
|
||||
"""Thread-safe cancellation token for cooperative cancellation."""
|
||||
|
||||
def __init__(self):
|
||||
self._event = threading.Event()
|
||||
|
||||
def cancel(self):
|
||||
self._event.set()
|
||||
|
||||
@property
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
|
||||
# ── Try to import Ray ──
|
||||
|
||||
RAY_AVAILABLE = False
|
||||
@@ -80,14 +97,19 @@ def _ray_process_chunk_impl(chunk, terrain_cache, buildings, osm_data, config):
|
||||
"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', []),
|
||||
_worker_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())
|
||||
@@ -162,13 +184,16 @@ def calculate_coverage_parallel(
|
||||
site_elevation: float,
|
||||
num_workers: Optional[int] = None,
|
||||
log_fn: Optional[Callable[[str], None]] = None,
|
||||
cancel_token: Optional[CancellationToken] = None,
|
||||
precomputed: Optional[Dict] = None,
|
||||
) -> Tuple[List[Dict], Dict[str, float]]:
|
||||
"""Calculate coverage points in parallel.
|
||||
|
||||
Uses Ray if available (shared memory, zero-copy numpy), otherwise
|
||||
falls back to sequential single-threaded calculation.
|
||||
falls back to ProcessPoolExecutor or sequential single-threaded calculation.
|
||||
|
||||
Same signature as before — drop-in replacement.
|
||||
cancel_token: cooperative cancellation — checked between chunks.
|
||||
precomputed: dict mapping (lat, lon) -> {distance, path_loss} from GPU pre-computation.
|
||||
"""
|
||||
if log_fn is None:
|
||||
log_fn = lambda msg: print(f"[PARALLEL] {msg}", flush=True)
|
||||
@@ -185,7 +210,7 @@ def calculate_coverage_parallel(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
terrain_cache, buildings, streets, water_bodies,
|
||||
vegetation_areas, site_elevation,
|
||||
num_workers, log_fn,
|
||||
num_workers, log_fn, cancel_token, precomputed,
|
||||
)
|
||||
except Exception as e:
|
||||
log_fn(f"Ray execution failed: {e} — falling back to sequential")
|
||||
@@ -198,7 +223,7 @@ def calculate_coverage_parallel(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
terrain_cache, buildings, streets, water_bodies,
|
||||
vegetation_areas, site_elevation,
|
||||
pool_workers, log_fn,
|
||||
pool_workers, log_fn, cancel_token, precomputed,
|
||||
)
|
||||
except Exception as e:
|
||||
log_fn(f"ProcessPool failed: {e} — falling back to sequential")
|
||||
@@ -208,7 +233,7 @@ def calculate_coverage_parallel(
|
||||
return _calculate_sequential(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
buildings, streets, water_bodies, vegetation_areas,
|
||||
site_elevation, log_fn,
|
||||
site_elevation, log_fn, cancel_token, precomputed,
|
||||
)
|
||||
|
||||
|
||||
@@ -219,15 +244,13 @@ def _calculate_with_ray(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
terrain_cache, buildings, streets, water_bodies,
|
||||
vegetation_areas, site_elevation,
|
||||
num_workers, log_fn,
|
||||
num_workers, log_fn, cancel_token=None, precomputed=None,
|
||||
):
|
||||
"""Execute using Ray shared-memory object store."""
|
||||
total_points = len(grid)
|
||||
log_fn(f"Ray mode: {total_points} points, {num_workers} workers")
|
||||
|
||||
# ── Put large data into Ray object store ──
|
||||
# Numpy arrays (terrain tiles) get zero-copy shared memory.
|
||||
# Python objects (buildings) get serialized once, stored in plasma.
|
||||
t_put = time.time()
|
||||
|
||||
terrain_ref = ray.put(terrain_cache)
|
||||
@@ -239,12 +262,15 @@ def _calculate_with_ray(
|
||||
})
|
||||
|
||||
cache_key = f"{site_dict['lat']:.4f},{site_dict['lon']:.4f},{len(buildings)}"
|
||||
config_ref = ray.put({
|
||||
config = {
|
||||
'site_dict': site_dict,
|
||||
'settings_dict': settings_dict,
|
||||
'site_elevation': site_elevation,
|
||||
'cache_key': cache_key,
|
||||
})
|
||||
}
|
||||
if precomputed:
|
||||
config['precomputed'] = precomputed
|
||||
config_ref = ray.put(config)
|
||||
|
||||
put_time = time.time() - t_put
|
||||
log_fn(f"ray.put() done in {put_time:.1f}s")
|
||||
@@ -273,9 +299,19 @@ def _calculate_with_ray(
|
||||
completed_chunks = 0
|
||||
|
||||
while remaining:
|
||||
# Check cancellation before waiting
|
||||
if cancel_token and cancel_token.is_cancelled:
|
||||
log_fn(f"Cancelled — aborting {len(remaining)} remaining Ray chunks")
|
||||
for ref in remaining:
|
||||
try:
|
||||
ray.cancel(ref, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
break
|
||||
|
||||
# Wait for at least 1 result, batch up to ~10% for progress logging
|
||||
batch = max(1, min(len(remaining), total_chunks // 10 or 1))
|
||||
done, remaining = ray.wait(remaining, num_returns=batch, timeout=600)
|
||||
done, remaining = ray.wait(remaining, num_returns=batch, timeout=30)
|
||||
|
||||
for ref in done:
|
||||
try:
|
||||
@@ -333,14 +369,19 @@ def _pool_worker_process_chunk(args):
|
||||
"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())
|
||||
@@ -352,7 +393,7 @@ def _calculate_with_process_pool(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
terrain_cache, buildings, streets, water_bodies,
|
||||
vegetation_areas, site_elevation,
|
||||
num_workers, log_fn,
|
||||
num_workers, log_fn, cancel_token=None, precomputed=None,
|
||||
):
|
||||
"""Execute using ProcessPoolExecutor with reduced workers to limit memory."""
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
@@ -375,6 +416,8 @@ def _calculate_with_process_pool(
|
||||
'settings_dict': settings_dict,
|
||||
'site_elevation': site_elevation,
|
||||
}
|
||||
if precomputed:
|
||||
config['precomputed'] = precomputed
|
||||
osm_data = {
|
||||
'streets': streets,
|
||||
'water_bodies': water_bodies,
|
||||
@@ -395,6 +438,13 @@ def _calculate_with_process_pool(
|
||||
|
||||
completed_chunks = 0
|
||||
for future in as_completed(futures):
|
||||
# Check cancellation between chunks
|
||||
if cancel_token and cancel_token.is_cancelled:
|
||||
log_fn(f"Cancelled — cancelling {len(futures) - completed_chunks - 1} pending futures")
|
||||
for f in futures:
|
||||
f.cancel()
|
||||
break
|
||||
|
||||
try:
|
||||
chunk_results = future.result()
|
||||
all_results.extend(chunk_results)
|
||||
@@ -428,7 +478,7 @@ def _calculate_with_process_pool(
|
||||
def _calculate_sequential(
|
||||
grid, point_elevations, site_dict, settings_dict,
|
||||
buildings, streets, water_bodies, vegetation_areas,
|
||||
site_elevation, log_fn,
|
||||
site_elevation, log_fn, cancel_token=None, precomputed=None,
|
||||
):
|
||||
"""Sequential fallback — no extra dependencies, runs in calling thread."""
|
||||
from app.services.coverage_service import CoverageService, SiteParams, CoverageSettings
|
||||
@@ -453,15 +503,26 @@ def _calculate_sequential(
|
||||
t0 = time.time()
|
||||
results = []
|
||||
for i, (lat, lon) in enumerate(grid):
|
||||
# Check cancellation
|
||||
if cancel_token and cancel_token.is_cancelled:
|
||||
log_fn(f"Sequential cancelled at {i}/{total}")
|
||||
break
|
||||
|
||||
if i % log_interval == 0:
|
||||
log_fn(f"Sequential: {i}/{total} ({i * 100 // total}%)")
|
||||
|
||||
point_elev = point_elevations.get((lat, lon), 0.0)
|
||||
|
||||
# Use precomputed values if available
|
||||
pre = precomputed.get((lat, lon)) if precomputed else None
|
||||
|
||||
point = svc._calculate_point_sync(
|
||||
site, lat, lon, settings,
|
||||
buildings, streets, spatial_idx,
|
||||
water_bodies, vegetation_areas,
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user