Phase 2.2: performance optimizations, debug tools, app close fix
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user