Phase 2.2: performance optimizations, debug tools, app close fix
This commit is contained in:
@@ -26,6 +26,12 @@ REGIONS = {
|
||||
"srtm_tiles": 18,
|
||||
"estimated_size_gb": 0.5,
|
||||
},
|
||||
"ukraine_west": {
|
||||
"name": "Western Ukraine",
|
||||
"bbox": [48.0, 22.0, 51.0, 26.0],
|
||||
"srtm_tiles": 12,
|
||||
"estimated_size_gb": 0.3,
|
||||
},
|
||||
"kyiv_region": {
|
||||
"name": "Kyiv Region",
|
||||
"bbox": [49.5, 29.5, 51.5, 32.5],
|
||||
|
||||
@@ -1,6 +1,44 @@
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import numpy as np
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
_coverage_log_file = None
|
||||
|
||||
def _clog(msg: str):
|
||||
"""Coverage debug log — always flushed, with timestamp and thread name.
|
||||
Writes to stdout, stderr, AND a file so output is always available."""
|
||||
global _coverage_log_file
|
||||
ts = time.strftime('%H:%M:%S')
|
||||
thr = threading.current_thread().name
|
||||
line = f"[COVERAGE {ts}] [{thr}] {msg}"
|
||||
print(line, flush=True)
|
||||
# Backup: also write to stderr in case stdout is broken
|
||||
try:
|
||||
sys.stderr.write(line + '\n')
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
# Backup: also write to a file
|
||||
try:
|
||||
if _coverage_log_file is None:
|
||||
log_dir = os.environ.get('RFCP_DATA_PATH', './data')
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_path = os.path.join(log_dir, 'coverage-debug.log')
|
||||
_coverage_log_file = open(log_path, 'a')
|
||||
_coverage_log_file.write(f"\n{'='*60}\n")
|
||||
_coverage_log_file.write(f"[COVERAGE {ts}] Log started\n")
|
||||
_coverage_log_file.flush()
|
||||
_coverage_log_file.write(line + '\n')
|
||||
_coverage_log_file.flush()
|
||||
except Exception:
|
||||
pass
|
||||
from pydantic import BaseModel
|
||||
from app.services.terrain_service import terrain_service, TerrainService
|
||||
from app.services.los_service import los_service
|
||||
@@ -142,6 +180,100 @@ class CoverageService:
|
||||
self.buildings = buildings_service
|
||||
self.los = los_service
|
||||
|
||||
async def _fetch_osm_grid_aligned(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
max_lat: float, max_lon: float,
|
||||
settings: CoverageSettings
|
||||
) -> dict:
|
||||
"""
|
||||
Fetch OSM data using 1-degree grid-aligned cells.
|
||||
|
||||
This ensures cache keys match the region download grid,
|
||||
so pre-cached data is actually used.
|
||||
"""
|
||||
t0 = time.time()
|
||||
|
||||
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))
|
||||
|
||||
cells = []
|
||||
for lat_int in range(lat_start, lat_end + 1):
|
||||
for lon_int in range(lon_start, lon_end + 1):
|
||||
cells.append((float(lat_int), float(lon_int),
|
||||
float(lat_int + 1), float(lon_int + 1)))
|
||||
|
||||
buildings: List[Building] = []
|
||||
streets: List[Street] = []
|
||||
water_bodies: List[WaterBody] = []
|
||||
vegetation_areas: List[VegetationArea] = []
|
||||
|
||||
cache_stats = {"buildings": "skip", "streets": "skip",
|
||||
"water": "skip", "vegetation": "skip"}
|
||||
|
||||
for cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon in cells:
|
||||
cell_label = f"[{cell_min_lat:.0f},{cell_min_lon:.0f}]"
|
||||
|
||||
if settings.use_buildings:
|
||||
t1 = time.time()
|
||||
chunk = await self.buildings.fetch_buildings(
|
||||
cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon
|
||||
)
|
||||
dt = time.time() - t1
|
||||
src = "CACHE" if dt < 0.5 else "API"
|
||||
buildings.extend(chunk)
|
||||
cache_stats["buildings"] = src
|
||||
_clog(f"Buildings {cell_label}: {len(chunk)} items ({src}, {dt:.1f}s)")
|
||||
|
||||
if settings.use_street_canyon:
|
||||
t1 = time.time()
|
||||
chunk = await street_canyon_service.fetch_streets(
|
||||
cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon
|
||||
)
|
||||
dt = time.time() - t1
|
||||
src = "CACHE" if dt < 0.5 else "API"
|
||||
streets.extend(chunk)
|
||||
cache_stats["streets"] = src
|
||||
_clog(f"Streets {cell_label}: {len(chunk)} items ({src}, {dt:.1f}s)")
|
||||
|
||||
if settings.use_water_reflection:
|
||||
t1 = time.time()
|
||||
chunk = await water_service.fetch_water_bodies(
|
||||
cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon
|
||||
)
|
||||
dt = time.time() - t1
|
||||
src = "CACHE" if dt < 0.5 else "API"
|
||||
water_bodies.extend(chunk)
|
||||
cache_stats["water"] = src
|
||||
_clog(f"Water {cell_label}: {len(chunk)} items ({src}, {dt:.1f}s)")
|
||||
|
||||
if settings.use_vegetation:
|
||||
t1 = time.time()
|
||||
chunk = await vegetation_service.fetch_vegetation(
|
||||
cell_min_lat, cell_min_lon, cell_max_lat, cell_max_lon
|
||||
)
|
||||
dt = time.time() - t1
|
||||
src = "CACHE" if dt < 0.5 else "API"
|
||||
vegetation_areas.extend(chunk)
|
||||
cache_stats["vegetation"] = src
|
||||
_clog(f"Vegetation {cell_label}: {len(chunk)} items ({src}, {dt:.1f}s)")
|
||||
|
||||
total_fetch = time.time() - t0
|
||||
_clog(f"OSM fetch total: {total_fetch:.1f}s "
|
||||
f"({len(cells)} cells, "
|
||||
f"{len(buildings)} bldgs, {len(streets)} streets, "
|
||||
f"{len(water_bodies)} water, {len(vegetation_areas)} veg)")
|
||||
_clog(f"Cache status: {cache_stats}")
|
||||
|
||||
return {
|
||||
"buildings": buildings,
|
||||
"streets": streets,
|
||||
"water_bodies": water_bodies,
|
||||
"vegetation_areas": vegetation_areas,
|
||||
}
|
||||
|
||||
async def calculate_coverage(
|
||||
self,
|
||||
site: SiteParams,
|
||||
@@ -152,6 +284,8 @@ class CoverageService:
|
||||
|
||||
Returns list of CoveragePoint with RSRP values
|
||||
"""
|
||||
calc_start = time.time()
|
||||
|
||||
# Apply preset if specified
|
||||
settings = apply_preset(settings)
|
||||
|
||||
@@ -163,6 +297,7 @@ class CoverageService:
|
||||
settings.radius,
|
||||
settings.resolution
|
||||
)
|
||||
_clog(f"Grid: {len(grid)} points, radius={settings.radius}m, res={settings.resolution}m")
|
||||
|
||||
# Calculate bbox for data fetching
|
||||
lat_delta = settings.radius / 111000
|
||||
@@ -173,48 +308,80 @@ class CoverageService:
|
||||
min_lon = site.lon - lon_delta
|
||||
max_lon = site.lon + lon_delta
|
||||
|
||||
# Fetch buildings (if enabled) and build spatial index
|
||||
buildings: List[Building] = []
|
||||
_clog(f"Bbox: [{min_lat:.4f}, {min_lon:.4f}, {max_lat:.4f}, {max_lon:.4f}]")
|
||||
|
||||
# ━━━ PHASE 1: Fetch OSM data ━━━
|
||||
_clog("━━━ PHASE 1: Fetching OSM data ━━━")
|
||||
t_osm = time.time()
|
||||
osm_data = await self._fetch_osm_grid_aligned(
|
||||
min_lat, min_lon, max_lat, max_lon, settings
|
||||
)
|
||||
osm_time = time.time() - t_osm
|
||||
|
||||
buildings = osm_data["buildings"]
|
||||
streets = osm_data["streets"]
|
||||
water_bodies = osm_data["water_bodies"]
|
||||
vegetation_areas = osm_data["vegetation_areas"]
|
||||
_clog(f"━━━ PHASE 1 done: {osm_time:.1f}s ━━━")
|
||||
|
||||
# Build spatial index for buildings
|
||||
spatial_idx: Optional[SpatialIndex] = None
|
||||
if settings.use_buildings:
|
||||
buildings = await self.buildings.fetch_buildings(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
if buildings:
|
||||
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
|
||||
spatial_idx = get_spatial_index(cache_key, buildings)
|
||||
if buildings:
|
||||
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
|
||||
spatial_idx = get_spatial_index(cache_key, buildings)
|
||||
|
||||
# Fetch streets (if street canyon enabled)
|
||||
streets: List[Street] = []
|
||||
if settings.use_street_canyon:
|
||||
streets = await street_canyon_service.fetch_streets(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
# ━━━ PHASE 2: Pre-load terrain ━━━
|
||||
_clog("━━━ PHASE 2: Pre-loading terrain ━━━")
|
||||
t_terrain = time.time()
|
||||
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)
|
||||
|
||||
# Fetch water bodies (if water reflection enabled)
|
||||
water_bodies: List[WaterBody] = []
|
||||
if settings.use_water_reflection:
|
||||
water_bodies = await water_service.fetch_water_bodies(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
site_elevation = self.terrain.get_elevation_sync(site.lat, site.lon)
|
||||
|
||||
# Fetch vegetation (if enabled)
|
||||
vegetation_areas: List[VegetationArea] = []
|
||||
if settings.use_vegetation:
|
||||
vegetation_areas = await vegetation_service.fetch_vegetation(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
|
||||
# Calculate coverage for each point
|
||||
point_elevations = {}
|
||||
for lat, lon in grid:
|
||||
point = await self._calculate_point(
|
||||
site, lat, lon,
|
||||
settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas
|
||||
)
|
||||
point_elevations[(lat, lon)] = self.terrain.get_elevation_sync(lat, lon)
|
||||
terrain_time = time.time() - t_terrain
|
||||
_clog(f"Tiles: {len(tile_names)}, site elev: {site_elevation:.0f}m, "
|
||||
f"pre-computed {len(grid)} elevations")
|
||||
_clog(f"━━━ PHASE 2 done: {terrain_time:.1f}s ━━━")
|
||||
|
||||
if point.rsrp >= settings.min_signal:
|
||||
points.append(point)
|
||||
# ━━━ PHASE 3: Point calculation (sync, in thread pool) ━━━
|
||||
_clog(f"━━━ PHASE 3: Calculating {len(grid)} points (threaded) ━━━")
|
||||
dominant_path_service._log_count = 0 # Reset diagnostic counter
|
||||
t_points = time.time()
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
points_time = time.time() - t_points
|
||||
total_time = time.time() - calc_start
|
||||
|
||||
_clog(f"━━━ PHASE 3 done: {points_time:.1f}s ━━━")
|
||||
_clog("=== RESULTS ===")
|
||||
_clog(f" Grid points: {len(grid)}")
|
||||
_clog(f" Result points: {len(points)}")
|
||||
_clog(f" OSM fetch: {osm_time:.1f}s")
|
||||
_clog(f" Terrain pre-load:{terrain_time:.1f}s")
|
||||
_clog(f" Point calc: {points_time:.1f}s "
|
||||
f"({points_time/max(1,len(grid))*1000:.1f}ms/point)")
|
||||
_clog(f" TOTAL: {total_time:.1f}s")
|
||||
_clog(f" Tiles in memory: {len(self.terrain._tile_cache)}")
|
||||
if any(v > 0.001 for v in timing.values()):
|
||||
_clog("=== PER-STEP BREAKDOWN ===")
|
||||
for step, dt in timing.items():
|
||||
if dt > 0.001:
|
||||
_clog(f" {step:20s} {dt:.3f}s "
|
||||
f"({dt/max(1,len(grid))*1000:.2f}ms/point)")
|
||||
|
||||
return points
|
||||
|
||||
@@ -280,7 +447,36 @@ class CoverageService:
|
||||
|
||||
return points
|
||||
|
||||
async def _calculate_point(
|
||||
def _run_point_loop(
|
||||
self, grid, site, settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas,
|
||||
site_elevation, point_elevations
|
||||
):
|
||||
"""Sync point loop - runs in ThreadPoolExecutor, bypasses event loop."""
|
||||
points = []
|
||||
timing = {"los": 0.0, "buildings": 0.0, "antenna": 0.0,
|
||||
"dominant_path": 0.0, "street_canyon": 0.0,
|
||||
"reflection": 0.0, "vegetation": 0.0}
|
||||
total = len(grid)
|
||||
log_interval = max(1, total // 20)
|
||||
|
||||
for i, (lat, lon) in enumerate(grid):
|
||||
if i % log_interval == 0:
|
||||
_clog(f"Progress: {i}/{total} ({i*100//total}%)")
|
||||
|
||||
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
|
||||
)
|
||||
if point.rsrp >= settings.min_signal:
|
||||
points.append(point)
|
||||
|
||||
_clog(f"Progress: {total}/{total} (100%)")
|
||||
return points, timing
|
||||
|
||||
def _calculate_point_sync(
|
||||
self,
|
||||
site: SiteParams,
|
||||
lat: float, lon: float,
|
||||
@@ -289,58 +485,62 @@ class CoverageService:
|
||||
streets: List[Street],
|
||||
spatial_idx: Optional[SpatialIndex],
|
||||
water_bodies: List[WaterBody],
|
||||
vegetation_areas: List[VegetationArea]
|
||||
vegetation_areas: List[VegetationArea],
|
||||
site_elevation: float,
|
||||
point_elevation: float,
|
||||
timing: dict
|
||||
) -> CoveragePoint:
|
||||
"""Calculate RSRP at a single point with all propagation models"""
|
||||
"""Fully synchronous point calculation. All terrain tiles must be pre-loaded."""
|
||||
|
||||
# Distance
|
||||
distance = TerrainService.haversine_distance(site.lat, site.lon, lat, lon)
|
||||
|
||||
if distance < 1:
|
||||
distance = 1 # Avoid division by zero
|
||||
distance = 1
|
||||
|
||||
# Base path loss (Okumura-Hata for urban)
|
||||
path_loss = self._okumura_hata(
|
||||
distance, site.frequency, site.height, 1.5
|
||||
)
|
||||
# Base path loss
|
||||
path_loss = self._okumura_hata(distance, site.frequency, site.height, 1.5)
|
||||
|
||||
# Antenna pattern loss (if directional)
|
||||
# Antenna pattern
|
||||
antenna_loss = 0.0
|
||||
if site.azimuth is not None and site.beamwidth:
|
||||
t0 = time.time()
|
||||
antenna_loss = self._antenna_pattern_loss(
|
||||
site.lat, site.lon, lat, lon,
|
||||
site.azimuth, site.beamwidth
|
||||
site.lat, site.lon, lat, lon, site.azimuth, site.beamwidth
|
||||
)
|
||||
timing["antenna"] += time.time() - t0
|
||||
|
||||
# Terrain loss (LoS check)
|
||||
# Terrain LOS (sync)
|
||||
terrain_loss = 0.0
|
||||
has_los = True
|
||||
|
||||
if settings.use_terrain:
|
||||
los_result = await self.los.check_line_of_sight(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5
|
||||
t0 = time.time()
|
||||
los_result = self.los.check_line_of_sight_sync(
|
||||
site.lat, site.lon, site.height, lat, lon, 1.5
|
||||
)
|
||||
has_los = los_result["has_los"]
|
||||
|
||||
if not has_los:
|
||||
clearance = los_result["clearance"]
|
||||
terrain_loss = self._diffraction_loss(clearance, site.frequency)
|
||||
terrain_loss = self._diffraction_loss(
|
||||
los_result["clearance"], site.frequency
|
||||
)
|
||||
timing["los"] += time.time() - t0
|
||||
|
||||
# Building loss — use spatial index for fast lookup
|
||||
# Building loss (spatial index)
|
||||
building_loss = 0.0
|
||||
t0 = time.time()
|
||||
nearby_buildings = (
|
||||
spatial_idx.query_line(site.lat, site.lon, lat, lon)
|
||||
if spatial_idx else buildings
|
||||
)
|
||||
|
||||
if settings.use_buildings and nearby_buildings:
|
||||
site_total_h = site.height + site_elevation
|
||||
point_total_h = 1.5 + point_elevation
|
||||
|
||||
if settings.use_materials:
|
||||
for building in nearby_buildings:
|
||||
intersection = self.buildings.line_intersects_building(
|
||||
site.lat, site.lon, site.height + await self.terrain.get_elevation(site.lat, site.lon),
|
||||
lat, lon, 1.5 + await self.terrain.get_elevation(lat, lon),
|
||||
building
|
||||
site.lat, site.lon, site_total_h,
|
||||
lat, lon, point_total_h, building
|
||||
)
|
||||
if intersection is not None:
|
||||
material = materials_service.detect_material(building.tags)
|
||||
@@ -352,21 +552,23 @@ class CoverageService:
|
||||
else:
|
||||
for building in nearby_buildings:
|
||||
intersection = self.buildings.line_intersects_building(
|
||||
site.lat, site.lon, site.height + await self.terrain.get_elevation(site.lat, site.lon),
|
||||
lat, lon, 1.5 + await self.terrain.get_elevation(lat, lon),
|
||||
building
|
||||
site.lat, site.lon, site_total_h,
|
||||
lat, lon, point_total_h, building
|
||||
)
|
||||
if intersection is not None:
|
||||
building_loss += 20.0
|
||||
has_los = False
|
||||
break
|
||||
timing["buildings"] += time.time() - t0
|
||||
|
||||
# Dominant path analysis
|
||||
if settings.use_dominant_path and nearby_buildings:
|
||||
paths = await dominant_path_service.find_dominant_paths(
|
||||
# Dominant path (sync) — uses spatial index for O(1) building lookups
|
||||
if settings.use_dominant_path and (spatial_idx or nearby_buildings):
|
||||
t0 = time.time()
|
||||
paths = dominant_path_service.find_dominant_paths_sync(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, nearby_buildings
|
||||
site.frequency, nearby_buildings,
|
||||
spatial_idx=spatial_idx
|
||||
)
|
||||
if paths:
|
||||
best_path = paths[0]
|
||||
@@ -375,10 +577,12 @@ class CoverageService:
|
||||
terrain_loss = 0
|
||||
building_loss = 0
|
||||
has_los = best_path.path_type == "direct" and not best_path.materials_crossed
|
||||
timing["dominant_path"] += time.time() - t0
|
||||
|
||||
# Street canyon model
|
||||
# Street canyon (sync)
|
||||
if settings.use_street_canyon and streets:
|
||||
canyon_loss, street_path = await street_canyon_service.calculate_street_canyon_loss(
|
||||
t0 = time.time()
|
||||
canyon_loss, _street_path = street_canyon_service.calculate_street_canyon_loss_sync(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, streets
|
||||
@@ -387,80 +591,76 @@ class CoverageService:
|
||||
path_loss = canyon_loss
|
||||
terrain_loss = 0
|
||||
building_loss = 0
|
||||
timing["street_canyon"] += time.time() - t0
|
||||
|
||||
# Vegetation loss
|
||||
# Vegetation (already sync)
|
||||
veg_loss = 0.0
|
||||
if settings.use_vegetation and vegetation_areas:
|
||||
t0 = time.time()
|
||||
veg_loss = vegetation_service.calculate_vegetation_loss(
|
||||
site.lat, site.lon, lat, lon,
|
||||
vegetation_areas, settings.season
|
||||
site.lat, site.lon, lat, lon, vegetation_areas, settings.season
|
||||
)
|
||||
timing["vegetation"] += time.time() - t0
|
||||
|
||||
# Reflections (building + ground/water)
|
||||
# Reflections (sync)
|
||||
reflection_gain = 0.0
|
||||
if settings.use_reflections and nearby_buildings:
|
||||
t0 = time.time()
|
||||
is_over_water = False
|
||||
if settings.use_water_reflection and water_bodies:
|
||||
is_over_water = water_service.point_over_water(lat, lon, water_bodies) is not None
|
||||
|
||||
reflection_paths = await reflection_service.find_reflection_paths(
|
||||
refl_paths = reflection_service.find_reflection_paths_sync(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, nearby_buildings,
|
||||
include_ground=True
|
||||
)
|
||||
|
||||
# If over water, replace ground reflection with stronger water reflection
|
||||
if is_over_water and reflection_paths:
|
||||
if is_over_water and refl_paths:
|
||||
water_path = reflection_service._calculate_ground_reflection(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, is_water=True
|
||||
)
|
||||
if water_path:
|
||||
reflection_paths = [
|
||||
p for p in reflection_paths if "ground" not in p.materials
|
||||
]
|
||||
reflection_paths.append(water_path)
|
||||
reflection_paths.sort(key=lambda p: p.total_loss)
|
||||
refl_paths = [p for p in refl_paths if "ground" not in p.materials]
|
||||
refl_paths.append(water_path)
|
||||
refl_paths.sort(key=lambda p: p.total_loss)
|
||||
|
||||
if reflection_paths:
|
||||
direct_rsrp = site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss - veg_loss
|
||||
if refl_paths:
|
||||
direct_rsrp = (site.power + site.gain - path_loss - antenna_loss
|
||||
- terrain_loss - building_loss - veg_loss)
|
||||
combined_rsrp = reflection_service.combine_paths(
|
||||
direct_rsrp, reflection_paths, site.power + site.gain
|
||||
direct_rsrp, refl_paths, site.power + site.gain
|
||||
)
|
||||
reflection_gain = max(0, combined_rsrp - direct_rsrp)
|
||||
timing["reflection"] += time.time() - t0
|
||||
elif settings.use_water_reflection and water_bodies and not settings.use_reflections:
|
||||
# Water reflection without full reflection model
|
||||
is_over_water = water_service.point_over_water(lat, lon, water_bodies) is not None
|
||||
if is_over_water:
|
||||
reflection_gain = 3.0 # ~3dB boost over water
|
||||
reflection_gain = 3.0
|
||||
|
||||
# Rain attenuation
|
||||
# Rain
|
||||
rain_loss = 0.0
|
||||
if settings.rain_rate > 0:
|
||||
rain_loss = weather_service.calculate_rain_attenuation(
|
||||
site.frequency,
|
||||
distance / 1000, # km
|
||||
settings.rain_rate
|
||||
site.frequency, distance / 1000, settings.rain_rate
|
||||
)
|
||||
|
||||
# Indoor penetration loss
|
||||
# Indoor
|
||||
indoor_loss = 0.0
|
||||
if settings.indoor_loss_type != "none":
|
||||
indoor_loss = indoor_service.calculate_indoor_loss(
|
||||
site.frequency,
|
||||
settings.indoor_loss_type
|
||||
site.frequency, settings.indoor_loss_type
|
||||
)
|
||||
|
||||
# Atmospheric absorption
|
||||
# Atmospheric
|
||||
atmo_loss = 0.0
|
||||
if settings.use_atmospheric:
|
||||
atmo_loss = atmospheric_service.calculate_atmospheric_loss(
|
||||
site.frequency,
|
||||
distance / 1000,
|
||||
settings.temperature_c,
|
||||
settings.humidity_percent
|
||||
site.frequency, distance / 1000,
|
||||
settings.temperature_c, settings.humidity_percent
|
||||
)
|
||||
|
||||
# Final RSRP
|
||||
@@ -470,18 +670,11 @@ class CoverageService:
|
||||
+ reflection_gain)
|
||||
|
||||
return CoveragePoint(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
rsrp=rsrp,
|
||||
distance=distance,
|
||||
has_los=has_los,
|
||||
terrain_loss=terrain_loss,
|
||||
building_loss=building_loss,
|
||||
reflection_gain=reflection_gain,
|
||||
vegetation_loss=veg_loss,
|
||||
rain_loss=rain_loss,
|
||||
indoor_loss=indoor_loss,
|
||||
atmospheric_loss=atmo_loss,
|
||||
lat=lat, lon=lon, rsrp=rsrp, distance=distance,
|
||||
has_los=has_los, terrain_loss=terrain_loss,
|
||||
building_loss=building_loss, reflection_gain=reflection_gain,
|
||||
vegetation_loss=veg_loss, rain_loss=rain_loss,
|
||||
indoor_loss=indoor_loss, atmospheric_loss=atmo_loss,
|
||||
)
|
||||
|
||||
def _okumura_hata(
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import time
|
||||
import numpy as np
|
||||
from typing import List, Tuple, Optional
|
||||
from typing import List, Tuple, Optional, TYPE_CHECKING
|
||||
from dataclasses import dataclass
|
||||
from app.services.terrain_service import terrain_service
|
||||
from app.services.buildings_service import buildings_service, Building
|
||||
from app.services.materials_service import materials_service, BuildingMaterial
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.spatial_index import SpatialIndex
|
||||
|
||||
|
||||
@dataclass
|
||||
class RayPath:
|
||||
@@ -30,6 +34,7 @@ class DominantPathService:
|
||||
|
||||
MAX_REFLECTIONS = 2
|
||||
MAX_PATHS = 3
|
||||
_log_count = 0 # Counter for diagnostic logging
|
||||
|
||||
async def find_dominant_paths(
|
||||
self,
|
||||
@@ -391,4 +396,250 @@ class DominantPathService:
|
||||
return 13 + 20 * np.log10(v)
|
||||
|
||||
|
||||
# ── Sync versions (terrain tiles must be pre-loaded) ──
|
||||
|
||||
def find_dominant_paths_sync(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
buildings: List[Building],
|
||||
spatial_idx: 'Optional[SpatialIndex]' = None
|
||||
) -> List[RayPath]:
|
||||
"""Sync version - uses spatial index for O(1) building lookups.
|
||||
|
||||
Args:
|
||||
buildings: fallback list (only used if spatial_idx is None)
|
||||
spatial_idx: grid-based spatial index for fast local queries
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# Use spatial index to get only buildings along the TX→RX line
|
||||
if spatial_idx:
|
||||
line_buildings = spatial_idx.query_line(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
else:
|
||||
line_buildings = buildings
|
||||
|
||||
direct = self._check_direct_path_sync(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz, line_buildings
|
||||
)
|
||||
if direct:
|
||||
paths.append(direct)
|
||||
|
||||
# Early termination: if direct path is valid and clear, skip expensive
|
||||
# reflection/diffraction — they won't produce a better path
|
||||
if direct and direct.is_valid and not direct.materials_crossed:
|
||||
return [direct]
|
||||
|
||||
# For reflections, only check buildings near the midpoint (~500m)
|
||||
if spatial_idx:
|
||||
mid_lat = (tx_lat + rx_lat) / 2
|
||||
mid_lon = (tx_lon + rx_lon) / 2
|
||||
# buffer_cells=5 with 0.001° cell ≈ 555m radius
|
||||
reflection_buildings = spatial_idx.query_point(mid_lat, mid_lon, buffer_cells=5)
|
||||
else:
|
||||
reflection_buildings = buildings
|
||||
|
||||
# Log building counts for first 3 points so user can verify filtering
|
||||
DominantPathService._log_count += 1
|
||||
if DominantPathService._log_count <= 3:
|
||||
import sys
|
||||
msg = (f"[DOMINANT_PATH] Point #{DominantPathService._log_count}: "
|
||||
f"line_bldgs={len(line_buildings)}, "
|
||||
f"refl_bldgs={len(reflection_buildings)}, "
|
||||
f"total_available={len(buildings)}, "
|
||||
f"spatial_idx={'YES' if spatial_idx else 'NO'}, "
|
||||
f"early_exit={'YES' if direct and direct.is_valid and not direct.materials_crossed else 'NO'}")
|
||||
print(msg, flush=True)
|
||||
try:
|
||||
sys.stderr.write(msg + '\n')
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
reflections = self._find_reflection_paths_sync(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz, reflection_buildings
|
||||
)
|
||||
paths.extend(reflections[:2])
|
||||
|
||||
if not direct or not direct.is_valid:
|
||||
diffracted = self._find_diffraction_path_sync(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz, spatial_idx=spatial_idx, buildings_fallback=buildings
|
||||
)
|
||||
if diffracted:
|
||||
paths.append(diffracted)
|
||||
|
||||
paths.sort(key=lambda p: p.path_loss)
|
||||
return paths[:self.MAX_PATHS]
|
||||
|
||||
def _check_direct_path_sync(
|
||||
self,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz,
|
||||
buildings: List[Building]
|
||||
) -> Optional[RayPath]:
|
||||
"""Sync direct path check using sync LOS.
|
||||
buildings should already be spatially filtered to the TX→RX line."""
|
||||
from app.services.los_service import los_service
|
||||
|
||||
los_result = los_service.check_line_of_sight_sync(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height
|
||||
)
|
||||
|
||||
if not los_result["has_los"]:
|
||||
distance = terrain_service.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
return RayPath(
|
||||
path_type="direct",
|
||||
total_distance=distance,
|
||||
path_loss=float('inf'),
|
||||
reflection_points=[],
|
||||
materials_crossed=[],
|
||||
is_valid=False
|
||||
)
|
||||
|
||||
materials_crossed = []
|
||||
for building in buildings:
|
||||
intersection = self._line_intersects_building_3d(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
building
|
||||
)
|
||||
if intersection:
|
||||
material = materials_service.detect_material(building.tags)
|
||||
materials_crossed.append(material)
|
||||
if len(materials_crossed) >= 3:
|
||||
break # Early termination — too many walls
|
||||
|
||||
distance = terrain_service.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
path_loss = self._calculate_path_loss(distance, frequency_mhz, tx_height, rx_height)
|
||||
|
||||
for material in materials_crossed:
|
||||
path_loss += materials_service.get_penetration_loss(material, frequency_mhz)
|
||||
|
||||
return RayPath(
|
||||
path_type="direct",
|
||||
total_distance=distance,
|
||||
path_loss=path_loss,
|
||||
reflection_points=[],
|
||||
materials_crossed=materials_crossed,
|
||||
is_valid=len(materials_crossed) < 3
|
||||
)
|
||||
|
||||
def _find_reflection_paths_sync(
|
||||
self,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz,
|
||||
buildings: List[Building]
|
||||
) -> List[RayPath]:
|
||||
"""Sync reflection paths.
|
||||
buildings should already be spatially filtered to nearby area."""
|
||||
reflection_paths = []
|
||||
direct_distance = terrain_service.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
|
||||
for building in buildings:
|
||||
reflection_point = self._find_reflection_point(
|
||||
tx_lat, tx_lon, rx_lat, rx_lon, building
|
||||
)
|
||||
if not reflection_point:
|
||||
continue
|
||||
|
||||
ref_lat, ref_lon = reflection_point
|
||||
dist1 = terrain_service.haversine_distance(tx_lat, tx_lon, ref_lat, ref_lon)
|
||||
dist2 = terrain_service.haversine_distance(ref_lat, ref_lon, rx_lat, rx_lon)
|
||||
total_distance = dist1 + dist2
|
||||
|
||||
if total_distance > direct_distance * 2:
|
||||
continue
|
||||
|
||||
path_loss = self._calculate_path_loss(total_distance, frequency_mhz, tx_height, rx_height)
|
||||
material = materials_service.detect_material(building.tags)
|
||||
path_loss += materials_service.get_reflection_loss(material)
|
||||
|
||||
reflection_paths.append(RayPath(
|
||||
path_type="reflected",
|
||||
total_distance=total_distance,
|
||||
path_loss=path_loss,
|
||||
reflection_points=[(ref_lat, ref_lon)],
|
||||
materials_crossed=[],
|
||||
is_valid=True
|
||||
))
|
||||
|
||||
return reflection_paths
|
||||
|
||||
def _find_diffraction_path_sync(
|
||||
self,
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz,
|
||||
spatial_idx: 'Optional[SpatialIndex]' = None,
|
||||
buildings_fallback: Optional[List[Building]] = None
|
||||
) -> Optional[RayPath]:
|
||||
"""Sync diffraction path.
|
||||
Uses spatial_idx.query_point at each sample for O(1) building lookup."""
|
||||
max_height = 0
|
||||
obstacle_lat, obstacle_lon = None, None
|
||||
|
||||
num_samples = 20
|
||||
for i in range(1, num_samples - 1):
|
||||
t = i / num_samples
|
||||
lat = tx_lat + t * (rx_lat - tx_lat)
|
||||
lon = tx_lon + t * (rx_lon - tx_lon)
|
||||
|
||||
terrain_elev = terrain_service.get_elevation_sync(lat, lon)
|
||||
if terrain_elev > max_height:
|
||||
max_height = terrain_elev
|
||||
obstacle_lat, obstacle_lon = lat, lon
|
||||
|
||||
# Use spatial index for O(1) lookup at this sample point
|
||||
if spatial_idx:
|
||||
local_buildings = spatial_idx.query_point(lat, lon, buffer_cells=1)
|
||||
else:
|
||||
local_buildings = buildings_fallback or []
|
||||
|
||||
for building in local_buildings:
|
||||
if buildings_service.point_in_building(lat, lon, building):
|
||||
if building.height > max_height:
|
||||
max_height = building.height
|
||||
obstacle_lat, obstacle_lon = lat, lon
|
||||
|
||||
if not obstacle_lat:
|
||||
return None
|
||||
|
||||
distance = terrain_service.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
|
||||
tx_elev = terrain_service.get_elevation_sync(tx_lat, tx_lon)
|
||||
rx_elev = terrain_service.get_elevation_sync(rx_lat, rx_lon)
|
||||
|
||||
tx_total = tx_elev + tx_height
|
||||
rx_total = rx_elev + rx_height
|
||||
|
||||
d1 = terrain_service.haversine_distance(tx_lat, tx_lon, obstacle_lat, obstacle_lon)
|
||||
los_height = tx_total + (rx_total - tx_total) * (d1 / distance) if distance > 0 else tx_total
|
||||
|
||||
clearance = los_height - max_height
|
||||
|
||||
diffraction_loss = self._knife_edge_loss(clearance, frequency_mhz, distance, d1)
|
||||
|
||||
path_loss = self._calculate_path_loss(distance, frequency_mhz, tx_height, rx_height)
|
||||
path_loss += diffraction_loss
|
||||
|
||||
return RayPath(
|
||||
path_type="diffracted",
|
||||
total_distance=distance,
|
||||
path_loss=path_loss,
|
||||
reflection_points=[(obstacle_lat, obstacle_lon)],
|
||||
materials_crossed=[],
|
||||
is_valid=True
|
||||
)
|
||||
|
||||
|
||||
dominant_path_service = DominantPathService()
|
||||
|
||||
@@ -98,6 +98,59 @@ class LineOfSightService:
|
||||
"profile": profile
|
||||
}
|
||||
|
||||
def check_line_of_sight_sync(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float = 1.5,
|
||||
num_samples: int = 50
|
||||
) -> dict:
|
||||
"""
|
||||
Sync LOS check - terrain tiles must be pre-loaded into memory.
|
||||
Returns dict with has_los, clearance, blocked_at (no profile for speed).
|
||||
"""
|
||||
profile = self.terrain.get_elevation_profile_sync(
|
||||
tx_lat, tx_lon, rx_lat, rx_lon, num_samples
|
||||
)
|
||||
|
||||
if not profile:
|
||||
return {"has_los": True, "clearance": 0, "blocked_at": None}
|
||||
|
||||
tx_ground = profile[0]["elevation"]
|
||||
rx_ground = profile[-1]["elevation"]
|
||||
|
||||
tx_total = tx_ground + tx_height
|
||||
rx_total = rx_ground + rx_height
|
||||
|
||||
total_distance = profile[-1]["distance"]
|
||||
|
||||
min_clearance = float('inf')
|
||||
blocked_at = None
|
||||
|
||||
for point in profile:
|
||||
d = point["distance"]
|
||||
terrain_elev = point["elevation"]
|
||||
|
||||
if total_distance == 0:
|
||||
los_height = tx_total
|
||||
else:
|
||||
los_height = tx_total + (rx_total - tx_total) * (d / total_distance)
|
||||
|
||||
effective_radius = self.K_FACTOR * self.EARTH_RADIUS
|
||||
curvature = (d * (total_distance - d)) / (2 * effective_radius)
|
||||
los_height_corrected = los_height - curvature
|
||||
clearance = los_height_corrected - terrain_elev
|
||||
|
||||
if clearance < min_clearance:
|
||||
min_clearance = clearance
|
||||
if clearance <= 0:
|
||||
blocked_at = d
|
||||
|
||||
return {
|
||||
"has_los": min_clearance > 0,
|
||||
"clearance": min_clearance,
|
||||
"blocked_at": blocked_at,
|
||||
}
|
||||
|
||||
async def calculate_fresnel_clearance(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
|
||||
@@ -273,4 +273,37 @@ class ReflectionService:
|
||||
return 10 * np.log10(total_power)
|
||||
|
||||
|
||||
def find_reflection_paths_sync(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
buildings: List[Building],
|
||||
include_ground: bool = True
|
||||
) -> List[ReflectionPath]:
|
||||
"""Sync version (no I/O in the async original)"""
|
||||
paths = []
|
||||
|
||||
for building in buildings:
|
||||
path = self._find_single_bounce(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz, building
|
||||
)
|
||||
if path:
|
||||
paths.append(path)
|
||||
|
||||
if include_ground:
|
||||
ground_path = self._calculate_ground_reflection(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz
|
||||
)
|
||||
if ground_path:
|
||||
paths.append(ground_path)
|
||||
|
||||
paths.sort(key=lambda p: p.total_loss)
|
||||
return paths[:5]
|
||||
|
||||
|
||||
reflection_service = ReflectionService()
|
||||
|
||||
@@ -21,6 +21,7 @@ class SpatialIndex:
|
||||
self.cell_size = cell_size
|
||||
self._grid: Dict[Tuple[int, int], List[Building]] = defaultdict(list)
|
||||
self._buildings: List[Building] = []
|
||||
self._buildings_by_id: Dict[int, Building] = {}
|
||||
|
||||
def _cell_key(self, lat: float, lon: float) -> Tuple[int, int]:
|
||||
"""Convert lat/lon to grid cell key"""
|
||||
@@ -30,6 +31,7 @@ class SpatialIndex:
|
||||
"""Build spatial index from buildings list"""
|
||||
self._grid.clear()
|
||||
self._buildings = buildings
|
||||
self._buildings_by_id = {b.id: b for b in buildings}
|
||||
|
||||
for building in buildings:
|
||||
# Get bounding box of building
|
||||
@@ -63,9 +65,7 @@ class SpatialIndex:
|
||||
for b in self._grid.get(key, []):
|
||||
results.add(b.id)
|
||||
|
||||
# Return buildings by id (deduped)
|
||||
id_set = results
|
||||
return [b for b in self._buildings if b.id in id_set]
|
||||
return [self._buildings_by_id[bid] for bid in results if bid in self._buildings_by_id]
|
||||
|
||||
def query_line(
|
||||
self,
|
||||
@@ -73,29 +73,37 @@ class SpatialIndex:
|
||||
lat2: float, lon2: float,
|
||||
buffer_cells: int = 1
|
||||
) -> List[Building]:
|
||||
"""Find buildings along a line (for LoS checks)"""
|
||||
"""Find buildings along a line by walking the actual cells it passes through.
|
||||
|
||||
Samples points along the line at cell_size intervals and queries
|
||||
a buffer around each sample — much faster than bounding-box scan
|
||||
for long lines.
|
||||
"""
|
||||
if not self._grid:
|
||||
return self._buildings
|
||||
|
||||
# Get bounding box cells of the line
|
||||
min_lat = min(lat1, lat2)
|
||||
max_lat = max(lat1, lat2)
|
||||
min_lon = min(lon1, lon2)
|
||||
max_lon = max(lon1, lon2)
|
||||
# Walk the line in cell_size steps, collecting unique cells
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
length = max(abs(dlat), abs(dlon))
|
||||
num_steps = max(1, int(length / self.cell_size) + 1)
|
||||
|
||||
min_clat = int(min_lat / self.cell_size) - buffer_cells
|
||||
max_clat = int(max_lat / self.cell_size) + buffer_cells
|
||||
min_clon = int(min_lon / self.cell_size) - buffer_cells
|
||||
max_clon = int(max_lon / self.cell_size) + buffer_cells
|
||||
visited_cells: set = set()
|
||||
for s in range(num_steps + 1):
|
||||
t = s / num_steps
|
||||
lat = lat1 + t * dlat
|
||||
lon = lon1 + t * dlon
|
||||
center = self._cell_key(lat, lon)
|
||||
for dy in range(-buffer_cells, buffer_cells + 1):
|
||||
for dx in range(-buffer_cells, buffer_cells + 1):
|
||||
visited_cells.add((center[0] + dy, center[1] + dx))
|
||||
|
||||
results = set()
|
||||
for clat in range(min_clat, max_clat + 1):
|
||||
for clon in range(min_clon, max_clon + 1):
|
||||
for b in self._grid.get((clat, clon), []):
|
||||
results.add(b.id)
|
||||
for key in visited_cells:
|
||||
for b in self._grid.get(key, []):
|
||||
results.add(b.id)
|
||||
|
||||
id_set = results
|
||||
return [b for b in self._buildings if b.id in id_set]
|
||||
return [self._buildings_by_id[bid] for bid in results if bid in self._buildings_by_id]
|
||||
|
||||
def query_bbox(
|
||||
self,
|
||||
@@ -117,8 +125,7 @@ class SpatialIndex:
|
||||
for b in self._grid.get((clat, clon), []):
|
||||
results.add(b.id)
|
||||
|
||||
id_set = results
|
||||
return [b for b in self._buildings if b.id in id_set]
|
||||
return [self._buildings_by_id[bid] for bid in results if bid in self._buildings_by_id]
|
||||
|
||||
|
||||
# Global cache of spatial indices
|
||||
|
||||
@@ -44,8 +44,10 @@ class StreetCanyonService:
|
||||
CORNER_LOSS_90 = 10.0 # dB for 90-degree turn
|
||||
CORNER_LOSS_45 = 4.0 # dB for 45-degree turn
|
||||
|
||||
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/streets"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
def __init__(self):
|
||||
import os
|
||||
self.data_path = Path(os.environ.get('RFCP_DATA_PATH', './data'))
|
||||
self.cache_dir = self.data_path / 'osm' / 'streets'
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
self._cache: dict[str, List[Street]] = {}
|
||||
|
||||
@@ -56,21 +58,28 @@ class StreetCanyonService:
|
||||
) -> List[Street]:
|
||||
"""Fetch street network from OSM"""
|
||||
|
||||
cache_key = f"{min_lat:.3f}_{min_lon:.3f}_{max_lat:.3f}_{max_lon:.3f}"
|
||||
cache_key = f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}"
|
||||
|
||||
# Check cache
|
||||
# Check memory cache
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Check disk cache
|
||||
cache_file = self.cache_dir / f"{cache_key}.json"
|
||||
if cache_file.exists():
|
||||
with open(cache_file) as f:
|
||||
data = json.load(f)
|
||||
streets = [Street(**s) for s in data]
|
||||
self._cache[cache_key] = streets
|
||||
return streets
|
||||
try:
|
||||
with open(cache_file) as f:
|
||||
data = json.load(f)
|
||||
streets = [Street(**s) for s in data]
|
||||
self._cache[cache_key] = streets
|
||||
print(f"[Streets] Cache hit for {cache_key}")
|
||||
return streets
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fetch from Overpass
|
||||
print(f"[Streets] Fetching from Overpass API for {cache_key}...")
|
||||
|
||||
query = f"""
|
||||
[out:json][timeout:30];
|
||||
way["highway"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
@@ -85,20 +94,21 @@ class StreetCanyonService:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
print(f"Street fetch error: {e}")
|
||||
print(f"[Streets] Fetch error: {e}")
|
||||
return []
|
||||
|
||||
streets = self._parse_streets(data)
|
||||
|
||||
# Cache
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump([{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"geometry": s.geometry,
|
||||
"width": s.width,
|
||||
"highway_type": s.highway_type
|
||||
} for s in streets], f)
|
||||
# Cache to disk
|
||||
if streets:
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump([{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"geometry": s.geometry,
|
||||
"width": s.width,
|
||||
"highway_type": s.highway_type
|
||||
} for s in streets], f)
|
||||
|
||||
self._cache[cache_key] = streets
|
||||
return streets
|
||||
@@ -360,4 +370,42 @@ class StreetCanyonService:
|
||||
return self.CORNER_LOSS_90 + (turn_angle - 90) * 0.2 # Extra loss for sharp turns
|
||||
|
||||
|
||||
def calculate_street_canyon_loss_sync(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
streets: List[Street]
|
||||
) -> Tuple[float, List[Tuple[float, float]]]:
|
||||
"""Sync version (no I/O in the async original)"""
|
||||
street_path = self._find_street_path(tx_lat, tx_lon, rx_lat, rx_lon, streets)
|
||||
|
||||
if not street_path:
|
||||
return float('inf'), []
|
||||
|
||||
total_loss = 0.0
|
||||
total_distance = 0.0
|
||||
|
||||
for i in range(len(street_path) - 1):
|
||||
p1 = street_path[i]
|
||||
p2 = street_path[i + 1]
|
||||
|
||||
from app.services.terrain_service import TerrainService
|
||||
segment_dist = TerrainService.haversine_distance(p1[0], p1[1], p2[0], p2[1])
|
||||
total_distance += segment_dist
|
||||
|
||||
if segment_dist > 0:
|
||||
segment_loss = 32.4 + 20 * np.log10(frequency_mhz) + 20 * np.log10(segment_dist / 1000 + 0.001)
|
||||
total_loss += segment_loss * (segment_dist / total_distance) if total_distance > 0 else 0
|
||||
|
||||
if i > 0:
|
||||
corner_angle = self._calculate_corner_angle(
|
||||
street_path[i - 1], p1, p2
|
||||
)
|
||||
corner_loss = self._corner_loss(corner_angle)
|
||||
total_loss += corner_loss
|
||||
|
||||
return total_loss, street_path
|
||||
|
||||
|
||||
street_canyon_service = StreetCanyonService()
|
||||
|
||||
@@ -168,6 +168,25 @@ class TerrainService:
|
||||
|
||||
return float(elevation)
|
||||
|
||||
def get_elevation_sync(self, lat: float, lon: float) -> float:
|
||||
"""Sync elevation lookup from memory cache. Returns 0.0 if tile not loaded."""
|
||||
tile_name = self.get_tile_name(lat, lon)
|
||||
tile = self._tile_cache.get(tile_name)
|
||||
if tile is None:
|
||||
return 0.0
|
||||
|
||||
size = tile.shape[0]
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
|
||||
row = int((1 - (lat - lat_int)) * (size - 1))
|
||||
col = int((lon - lon_int) * (size - 1))
|
||||
row = max(0, min(row, size - 1))
|
||||
col = max(0, min(col, size - 1))
|
||||
|
||||
elevation = tile[row, col]
|
||||
return 0.0 if elevation == -32768 else float(elevation)
|
||||
|
||||
async def get_elevation_profile(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
@@ -193,6 +212,30 @@ class TerrainService:
|
||||
|
||||
return profile
|
||||
|
||||
def get_elevation_profile_sync(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float,
|
||||
num_points: int = 50
|
||||
) -> List[dict]:
|
||||
"""Sync elevation profile - tiles must be pre-loaded into memory cache."""
|
||||
lats = np.linspace(lat1, lat2, num_points)
|
||||
lons = np.linspace(lon1, lon2, num_points)
|
||||
|
||||
total_distance = self.haversine_distance(lat1, lon1, lat2, lon2)
|
||||
distances = np.linspace(0, total_distance, num_points)
|
||||
|
||||
profile = []
|
||||
for i in range(num_points):
|
||||
profile.append({
|
||||
"lat": float(lats[i]),
|
||||
"lon": float(lons[i]),
|
||||
"elevation": self.get_elevation_sync(float(lats[i]), float(lons[i])),
|
||||
"distance": float(distances[i])
|
||||
})
|
||||
|
||||
return profile
|
||||
|
||||
async def ensure_tiles_for_bbox(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
|
||||
Reference in New Issue
Block a user