143 lines
4.4 KiB
Python
143 lines
4.4 KiB
Python
"""
|
|
Tile-based processing for large radius coverage calculations.
|
|
|
|
When radius > 10km, the coverage circle is split into 5km sub-tiles.
|
|
Each tile is processed independently — OSM data and terrain are loaded
|
|
per-tile and freed between tiles, keeping peak RAM usage bounded.
|
|
|
|
Usage:
|
|
from app.services.tile_processor import (
|
|
generate_tile_grid, partition_grid_to_tiles,
|
|
TILE_THRESHOLD_M, get_adaptive_worker_count,
|
|
)
|
|
|
|
if radius_m > TILE_THRESHOLD_M:
|
|
tiles = generate_tile_grid(center_lat, center_lon, radius_m)
|
|
tile_grids = partition_grid_to_tiles(grid, tiles)
|
|
"""
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import List, Tuple, Dict
|
|
|
|
|
|
# Use tiled processing for radius above this threshold
|
|
TILE_THRESHOLD_M = 10000 # 10 km
|
|
|
|
# Default tile size — 5km balances overhead vs memory usage
|
|
DEFAULT_TILE_SIZE_M = 5000 # 5 km
|
|
|
|
|
|
@dataclass
|
|
class Tile:
|
|
"""A rectangular sub-tile of the coverage area."""
|
|
bbox: Tuple[float, float, float, float] # (min_lat, min_lon, max_lat, max_lon)
|
|
index: Tuple[int, int] # (row, col) in tile grid
|
|
|
|
|
|
def generate_tile_grid(
|
|
center_lat: float,
|
|
center_lon: float,
|
|
radius_m: float,
|
|
tile_size_m: float = DEFAULT_TILE_SIZE_M,
|
|
) -> List[Tile]:
|
|
"""Generate grid of tiles covering the coverage circle.
|
|
|
|
Only includes tiles that actually intersect the coverage circle.
|
|
Tiles are ordered row-by-row from SW to NE.
|
|
"""
|
|
cos_lat = math.cos(math.radians(center_lat))
|
|
|
|
# Full coverage area in degrees
|
|
lat_delta = radius_m / 111000
|
|
lon_delta = radius_m / (111000 * cos_lat)
|
|
|
|
# Number of tiles along each axis
|
|
n_tiles = max(1, math.ceil(radius_m * 2 / tile_size_m))
|
|
|
|
# Tile size in degrees
|
|
tile_lat = (2 * lat_delta) / n_tiles
|
|
tile_lon = (2 * lon_delta) / n_tiles
|
|
|
|
base_lat = center_lat - lat_delta
|
|
base_lon = center_lon - lon_delta
|
|
|
|
tiles = []
|
|
for row in range(n_tiles):
|
|
for col in range(n_tiles):
|
|
min_lat = base_lat + row * tile_lat
|
|
max_lat = base_lat + (row + 1) * tile_lat
|
|
min_lon = base_lon + col * tile_lon
|
|
max_lon = base_lon + (col + 1) * tile_lon
|
|
|
|
bbox = (min_lat, min_lon, max_lat, max_lon)
|
|
|
|
if _tile_intersects_circle(bbox, center_lat, center_lon, radius_m, cos_lat):
|
|
tiles.append(Tile(bbox=bbox, index=(row, col)))
|
|
|
|
return tiles
|
|
|
|
|
|
def _tile_intersects_circle(
|
|
bbox: Tuple[float, float, float, float],
|
|
center_lat: float,
|
|
center_lon: float,
|
|
radius_m: float,
|
|
cos_lat: float,
|
|
) -> bool:
|
|
"""Check if tile bbox intersects the coverage circle.
|
|
|
|
Uses fast equirectangular approximation — tiles are small (5km)
|
|
so full haversine is unnecessary for intersection testing.
|
|
"""
|
|
min_lat, min_lon, max_lat, max_lon = bbox
|
|
|
|
# Closest point on bbox to circle center
|
|
closest_lat = max(min_lat, min(center_lat, max_lat))
|
|
closest_lon = max(min_lon, min(center_lon, max_lon))
|
|
|
|
# Approximate distance in meters (equirectangular)
|
|
dlat = (closest_lat - center_lat) * 111000
|
|
dlon = (closest_lon - center_lon) * 111000 * cos_lat
|
|
dist_sq = dlat * dlat + dlon * dlon
|
|
|
|
return dist_sq <= radius_m * radius_m
|
|
|
|
|
|
def get_adaptive_worker_count(radius_m: float, base_workers: int) -> int:
|
|
"""Scale down workers for large calculations to prevent combined memory explosion.
|
|
|
|
Large radius = more buildings per tile = more memory per worker.
|
|
Reducing workers keeps total worker memory bounded.
|
|
"""
|
|
if radius_m > 30000:
|
|
return min(base_workers, 2)
|
|
elif radius_m > 20000:
|
|
return min(base_workers, 3)
|
|
elif radius_m > 10000:
|
|
return min(base_workers, 4)
|
|
return base_workers
|
|
|
|
|
|
def partition_grid_to_tiles(
|
|
grid: List[Tuple[float, float]],
|
|
tiles: List[Tile],
|
|
) -> Dict[Tuple[int, int], List[Tuple[float, float]]]:
|
|
"""Partition grid points into tiles by bbox containment.
|
|
|
|
Returns dict mapping tile index -> list of (lat, lon) points.
|
|
Points on tile boundaries are assigned to the first matching tile.
|
|
"""
|
|
tile_grids: Dict[Tuple[int, int], List[Tuple[float, float]]] = {
|
|
t.index: [] for t in tiles
|
|
}
|
|
|
|
for lat, lon in grid:
|
|
for tile in tiles:
|
|
min_lat, min_lon, max_lat, max_lon = tile.bbox
|
|
if min_lat <= lat <= max_lat and min_lon <= lon <= max_lon:
|
|
tile_grids[tile.index].append((lat, lon))
|
|
break
|
|
|
|
return tile_grids
|