@mytec: iter3.4.0 ready for testing

This commit is contained in:
2026-02-02 21:58:03 +02:00
parent 867ee3d0f4
commit 57106df5ae
8 changed files with 742 additions and 19 deletions

View File

@@ -1,3 +1,4 @@
import gc
import math
import os
import sys
@@ -426,18 +427,28 @@ class CoverageService:
settings: CoverageSettings,
cancel_token: Optional[CancellationToken] = None,
progress_fn: Optional[Callable[[str, float], None]] = None,
tile_callback: Optional[Callable] = None,
) -> List[CoveragePoint]:
"""
Calculate coverage grid for a single site
Returns list of CoveragePoint with RSRP values.
progress_fn(phase, pct): optional callback for progress updates (0.0-1.0).
tile_callback(points, tile_idx, total_tiles): optional callback for per-tile
partial results when using tiled processing (radius > 10km).
"""
calc_start = time.time()
# Apply preset if specified
settings = apply_preset(settings)
# ── Tiled processing for large radius ──
from app.services.tile_processor import TILE_THRESHOLD_M
if settings.radius > TILE_THRESHOLD_M:
return await self.calculate_coverage_tiled(
site, settings, cancel_token, progress_fn, tile_callback
)
points = []
# Generate grid
@@ -660,12 +671,14 @@ class CoverageService:
settings: CoverageSettings,
cancel_token: Optional[CancellationToken] = None,
progress_fn: Optional[Callable[[str, float], None]] = None,
tile_callback: Optional[Callable] = None,
) -> List[CoveragePoint]:
"""
Calculate combined coverage from multiple sites
Best server (strongest signal) wins at each point
progress_fn(phase, pct): optional callback for progress updates (0.0-1.0).
tile_callback: forwarded to calculate_coverage for progressive results.
"""
if not sites:
return []
@@ -691,6 +704,7 @@ class CoverageService:
self.calculate_coverage(
site, settings, cancel_token,
progress_fn=_make_site_progress(i) if progress_fn else None,
tile_callback=tile_callback,
)
for i, site in enumerate(sites)
])
@@ -707,6 +721,208 @@ class CoverageService:
return list(point_map.values())
async def calculate_coverage_tiled(
self,
site: SiteParams,
settings: CoverageSettings,
cancel_token: Optional[CancellationToken] = None,
progress_fn: Optional[Callable[[str, float], None]] = None,
tile_callback: Optional[Callable] = None,
) -> List[CoveragePoint]:
"""Tile-based coverage for large radius (>10km).
Splits the coverage area into 5km sub-tiles. Each tile loads its
own OSM data and terrain, processes its grid points, then frees
memory before moving to the next tile. This keeps peak RAM
bounded regardless of total coverage area.
tile_callback(points, tile_idx, total_tiles): async callback
invoked with partial results after each tile completes.
"""
from app.services.tile_processor import (
generate_tile_grid, partition_grid_to_tiles, get_adaptive_worker_count,
)
calc_start = time.time()
# NOTE: settings already has preset applied by calculate_coverage()
# Generate full adaptive grid (lightweight — just coordinate tuples)
grid = self._generate_grid(
site.lat, site.lon, settings.radius, settings.resolution,
)
_clog(f"Tiled mode: {len(grid)} total grid points, radius={settings.radius}m")
# Generate tiles and partition grid points
tiles = generate_tile_grid(site.lat, site.lon, settings.radius)
total_tiles = len(tiles)
tile_grids = partition_grid_to_tiles(grid, tiles)
_clog(f"Generated {total_tiles} tiles")
# Free full grid reference
del grid
site_elevation: Optional[float] = None
all_points: List[CoveragePoint] = []
for tile_idx, tile in enumerate(tiles):
if cancel_token and cancel_token.is_cancelled:
_clog("Tiled calculation cancelled")
break
tile_grid = tile_grids.get(tile.index, [])
if not tile_grid:
continue
tile_start = time.time()
min_lat, min_lon, max_lat, max_lon = tile.bbox
_clog(f"━━━ Tile {tile_idx + 1}/{total_tiles}: "
f"{len(tile_grid)} points ━━━")
# Per-tile progress mapped to overall progress range
def _tile_progress(phase: str, pct: float, _idx=tile_idx):
if progress_fn:
overall = (_idx + pct) / total_tiles
progress_fn(
f"Tile {_idx + 1}/{total_tiles}: {phase}", overall,
)
# ── 1. Fetch OSM data for this tile ──
_tile_progress("Fetching map data", 0.10)
await asyncio.sleep(0)
osm_data = await self._fetch_osm_grid_aligned(
min_lat, min_lon, max_lat, max_lon, settings,
)
buildings = _filter_buildings_to_bbox(
osm_data["buildings"], min_lat, min_lon, max_lat, max_lon,
site.lat, site.lon, _clog,
)
streets = _filter_osm_list_to_bbox(
osm_data["streets"], min_lat, min_lon, max_lat, max_lon,
)
water_bodies = _filter_osm_list_to_bbox(
osm_data["water_bodies"], min_lat, min_lon, max_lat, max_lon,
)
vegetation_areas = _filter_osm_list_to_bbox(
osm_data["vegetation_areas"], min_lat, min_lon, max_lat, max_lon,
max_count=5000,
)
spatial_idx: Optional[SpatialIndex] = None
if buildings:
cache_key = f"tile_{tile_idx}_{min_lat:.3f},{min_lon:.3f}"
spatial_idx = get_spatial_index(cache_key, buildings)
# ── 2. Pre-load terrain for this tile ──
_tile_progress("Loading terrain", 0.25)
await asyncio.sleep(0)
tile_names = await self.terrain.ensure_tiles_for_bbox(
min_lat, min_lon, max_lat, max_lon,
)
for tn in tile_names:
self.terrain._load_tile(tn)
if site_elevation is None:
site_elevation = self.terrain.get_elevation_sync(
site.lat, site.lon,
)
point_elevations = {}
for lat, lon in tile_grid:
point_elevations[(lat, lon)] = self.terrain.get_elevation_sync(
lat, lon,
)
# ── 3. Precompute distances / path loss ──
_tile_progress("Pre-computing propagation", 0.35)
await asyncio.sleep(0)
from app.services.gpu_service import gpu_service
grid_lats = np.array([lat for lat, _lon in tile_grid])
grid_lons = np.array([_lon for _lat, _lon in tile_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,
environment=getattr(settings, 'environment', 'urban'),
)
precomputed = {}
for i, (lat, lon) in enumerate(tile_grid):
precomputed[(lat, lon)] = {
'distance': float(pre_distances[i]),
'path_loss': float(pre_path_loss[i]),
}
# ── 4. Calculate points (parallel with adaptive workers) ──
_tile_progress("Calculating coverage", 0.40)
await asyncio.sleep(0)
num_workers = get_adaptive_worker_count(
settings.radius, get_cpu_count(),
)
use_parallel = len(tile_grid) > 100 and num_workers > 1
if use_parallel:
loop = asyncio.get_event_loop()
result_dicts, _timing = await loop.run_in_executor(
None,
lambda: calculate_coverage_parallel(
tile_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,
),
)
tile_points = [CoveragePoint(**d) for d in result_dicts]
else:
loop = asyncio.get_event_loop()
tile_points, _timing = await loop.run_in_executor(
None,
lambda: self._run_point_loop(
tile_grid, site, settings, buildings, streets,
spatial_idx, water_bodies, vegetation_areas,
site_elevation, point_elevations,
cancel_token=cancel_token,
precomputed=precomputed,
),
)
all_points.extend(tile_points)
# Send partial results via callback
if tile_callback and tile_points:
await tile_callback(tile_points, tile_idx, total_tiles)
tile_time = time.time() - tile_start
_clog(f"Tile {tile_idx + 1}/{total_tiles} done: "
f"{len(tile_points)} points in {tile_time:.1f}s")
# ── 5. Free memory ──
del buildings, streets, water_bodies, vegetation_areas
del osm_data, spatial_idx, point_elevations, precomputed
del pre_distances, pre_path_loss, grid_lats, grid_lons
gc.collect()
total_time = time.time() - calc_start
_clog(f"━━━ Tiled calculation complete: "
f"{len(all_points)} points in {total_time:.1f}s ━━━")
if progress_fn:
progress_fn("Finalizing", 0.95)
await asyncio.sleep(0)
return all_points
# Adaptive resolution zone boundaries (meters)
_ADAPTIVE_ZONES = [
(0, 2000), # Inner: full user resolution