@mytec: iter1.6 ready for testing
This commit is contained in:
@@ -9,6 +9,9 @@ from app.services.materials_service import materials_service
|
||||
from app.services.dominant_path_service import dominant_path_service
|
||||
from app.services.street_canyon_service import street_canyon_service, Street
|
||||
from app.services.reflection_service import reflection_service
|
||||
from app.services.spatial_index import get_spatial_index, SpatialIndex
|
||||
from app.services.water_service import water_service, WaterBody
|
||||
from app.services.vegetation_service import vegetation_service, VegetationArea
|
||||
|
||||
|
||||
class CoveragePoint(BaseModel):
|
||||
@@ -19,7 +22,8 @@ class CoveragePoint(BaseModel):
|
||||
has_los: bool
|
||||
terrain_loss: float # dB
|
||||
building_loss: float # dB
|
||||
reflection_gain: float = 0.0 # dB (NEW)
|
||||
reflection_gain: float = 0.0 # dB
|
||||
vegetation_loss: float = 0.0 # dB
|
||||
|
||||
|
||||
class CoverageSettings(BaseModel):
|
||||
@@ -34,6 +38,11 @@ class CoverageSettings(BaseModel):
|
||||
use_dominant_path: bool = False
|
||||
use_street_canyon: bool = False
|
||||
use_reflections: bool = False
|
||||
use_water_reflection: bool = False
|
||||
use_vegetation: bool = False
|
||||
|
||||
# Vegetation season
|
||||
season: str = "summer"
|
||||
|
||||
# Preset
|
||||
preset: Optional[str] = None # fast, standard, detailed, full
|
||||
@@ -48,6 +57,8 @@ PRESETS = {
|
||||
"use_dominant_path": False,
|
||||
"use_street_canyon": False,
|
||||
"use_reflections": False,
|
||||
"use_water_reflection": False,
|
||||
"use_vegetation": False,
|
||||
},
|
||||
"standard": {
|
||||
"use_terrain": True,
|
||||
@@ -56,6 +67,8 @@ PRESETS = {
|
||||
"use_dominant_path": False,
|
||||
"use_street_canyon": False,
|
||||
"use_reflections": False,
|
||||
"use_water_reflection": False,
|
||||
"use_vegetation": False,
|
||||
},
|
||||
"detailed": {
|
||||
"use_terrain": True,
|
||||
@@ -64,6 +77,8 @@ PRESETS = {
|
||||
"use_dominant_path": True,
|
||||
"use_street_canyon": False,
|
||||
"use_reflections": False,
|
||||
"use_water_reflection": False,
|
||||
"use_vegetation": True,
|
||||
},
|
||||
"full": {
|
||||
"use_terrain": True,
|
||||
@@ -72,6 +87,8 @@ PRESETS = {
|
||||
"use_dominant_path": True,
|
||||
"use_street_canyon": True,
|
||||
"use_reflections": True,
|
||||
"use_water_reflection": True,
|
||||
"use_vegetation": True,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -98,7 +115,7 @@ class SiteParams(BaseModel):
|
||||
class CoverageService:
|
||||
"""
|
||||
RF Coverage calculation with terrain, buildings, materials,
|
||||
dominant path, street canyon, and reflections
|
||||
dominant path, street canyon, reflections, water, and vegetation
|
||||
"""
|
||||
|
||||
EARTH_RADIUS = 6371000
|
||||
@@ -134,27 +151,49 @@ class CoverageService:
|
||||
lat_delta = settings.radius / 111000
|
||||
lon_delta = settings.radius / (111000 * np.cos(np.radians(site.lat)))
|
||||
|
||||
# Fetch buildings for coverage area (if enabled)
|
||||
min_lat = site.lat - lat_delta
|
||||
max_lat = site.lat + lat_delta
|
||||
min_lon = site.lon - lon_delta
|
||||
max_lon = site.lon + lon_delta
|
||||
|
||||
# Fetch buildings (if enabled) and build spatial index
|
||||
buildings: List[Building] = []
|
||||
spatial_idx: Optional[SpatialIndex] = None
|
||||
if settings.use_buildings:
|
||||
buildings = await self.buildings.fetch_buildings(
|
||||
site.lat - lat_delta, site.lon - lon_delta,
|
||||
site.lat + lat_delta, site.lon + lon_delta
|
||||
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)
|
||||
|
||||
# Fetch streets (if street canyon enabled)
|
||||
streets: List[Street] = []
|
||||
if settings.use_street_canyon:
|
||||
streets = await street_canyon_service.fetch_streets(
|
||||
site.lat - lat_delta, site.lon - lon_delta,
|
||||
site.lat + lat_delta, site.lon + lon_delta
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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
|
||||
for lat, lon in grid:
|
||||
point = await self._calculate_point(
|
||||
site, lat, lon,
|
||||
settings, buildings, streets
|
||||
settings, buildings, streets,
|
||||
spatial_idx, water_bodies, vegetation_areas
|
||||
)
|
||||
|
||||
if point.rsrp >= settings.min_signal:
|
||||
@@ -230,7 +269,10 @@ class CoverageService:
|
||||
lat: float, lon: float,
|
||||
settings: CoverageSettings,
|
||||
buildings: List[Building],
|
||||
streets: List[Street]
|
||||
streets: List[Street],
|
||||
spatial_idx: Optional[SpatialIndex],
|
||||
water_bodies: List[WaterBody],
|
||||
vegetation_areas: List[VegetationArea]
|
||||
) -> CoveragePoint:
|
||||
"""Calculate RSRP at a single point with all propagation models"""
|
||||
|
||||
@@ -242,7 +284,7 @@ class CoverageService:
|
||||
|
||||
# Base path loss (Okumura-Hata for urban)
|
||||
path_loss = self._okumura_hata(
|
||||
distance, site.frequency, site.height, 1.5 # 1.5m receiver height
|
||||
distance, site.frequency, site.height, 1.5
|
||||
)
|
||||
|
||||
# Antenna pattern loss (if directional)
|
||||
@@ -260,22 +302,24 @@ class CoverageService:
|
||||
if settings.use_terrain:
|
||||
los_result = await self.los.check_line_of_sight(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5 # receiver at 1.5m
|
||||
lat, lon, 1.5
|
||||
)
|
||||
has_los = los_result["has_los"]
|
||||
|
||||
if not has_los:
|
||||
# Add diffraction loss based on clearance
|
||||
clearance = los_result["clearance"]
|
||||
terrain_loss = self._diffraction_loss(clearance, site.frequency)
|
||||
|
||||
# Building loss (with optional material awareness)
|
||||
# Building loss — use spatial index for fast lookup
|
||||
building_loss = 0.0
|
||||
nearby_buildings = (
|
||||
spatial_idx.query_line(site.lat, site.lon, lat, lon)
|
||||
if spatial_idx else buildings
|
||||
)
|
||||
|
||||
if settings.use_buildings and buildings:
|
||||
if settings.use_buildings and nearby_buildings:
|
||||
if settings.use_materials:
|
||||
# Material-aware building loss
|
||||
for building in buildings:
|
||||
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),
|
||||
@@ -287,30 +331,28 @@ class CoverageService:
|
||||
material, site.frequency
|
||||
)
|
||||
has_los = False
|
||||
break # One building is enough
|
||||
break
|
||||
else:
|
||||
# Simple building loss (legacy behavior)
|
||||
for building in buildings:
|
||||
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
|
||||
)
|
||||
if intersection is not None:
|
||||
building_loss += 20.0 # Default concrete
|
||||
building_loss += 20.0
|
||||
has_los = False
|
||||
break
|
||||
|
||||
# Dominant path analysis (find best route)
|
||||
if settings.use_dominant_path and buildings:
|
||||
# Dominant path analysis
|
||||
if settings.use_dominant_path and nearby_buildings:
|
||||
paths = await dominant_path_service.find_dominant_paths(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, buildings
|
||||
site.frequency, nearby_buildings
|
||||
)
|
||||
if paths:
|
||||
best_path = paths[0]
|
||||
# Use best path's loss if it's better
|
||||
if best_path.is_valid and best_path.path_loss < (path_loss + terrain_loss + building_loss):
|
||||
path_loss = best_path.path_loss
|
||||
terrain_loss = 0
|
||||
@@ -324,30 +366,62 @@ class CoverageService:
|
||||
lat, lon, 1.5,
|
||||
site.frequency, streets
|
||||
)
|
||||
# Use canyon loss if better than current total
|
||||
if canyon_loss < (path_loss + terrain_loss + building_loss):
|
||||
path_loss = canyon_loss
|
||||
terrain_loss = 0
|
||||
building_loss = 0
|
||||
|
||||
# Reflections
|
||||
# Vegetation loss
|
||||
veg_loss = 0.0
|
||||
if settings.use_vegetation and vegetation_areas:
|
||||
veg_loss = vegetation_service.calculate_vegetation_loss(
|
||||
site.lat, site.lon, lat, lon,
|
||||
vegetation_areas, settings.season
|
||||
)
|
||||
|
||||
# Reflections (building + ground/water)
|
||||
reflection_gain = 0.0
|
||||
if settings.use_reflections and buildings:
|
||||
if settings.use_reflections and nearby_buildings:
|
||||
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(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5,
|
||||
site.frequency, buildings
|
||||
site.frequency, nearby_buildings,
|
||||
include_ground=True
|
||||
)
|
||||
|
||||
# If over water, replace ground reflection with stronger water reflection
|
||||
if is_over_water and reflection_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)
|
||||
|
||||
if reflection_paths:
|
||||
# Combine direct and reflected signals
|
||||
direct_rsrp = site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss
|
||||
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
|
||||
)
|
||||
reflection_gain = max(0, combined_rsrp - direct_rsrp)
|
||||
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
|
||||
|
||||
# Final RSRP
|
||||
rsrp = site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss + reflection_gain
|
||||
rsrp = (site.power + site.gain - path_loss - antenna_loss
|
||||
- terrain_loss - building_loss - veg_loss + reflection_gain)
|
||||
|
||||
return CoveragePoint(
|
||||
lat=lat,
|
||||
@@ -357,30 +431,25 @@ class CoverageService:
|
||||
has_los=has_los,
|
||||
terrain_loss=terrain_loss,
|
||||
building_loss=building_loss,
|
||||
reflection_gain=reflection_gain
|
||||
reflection_gain=reflection_gain,
|
||||
vegetation_loss=veg_loss
|
||||
)
|
||||
|
||||
def _okumura_hata(
|
||||
self,
|
||||
distance: float, # meters
|
||||
frequency: float, # MHz
|
||||
tx_height: float, # meters
|
||||
rx_height: float # meters
|
||||
distance: float,
|
||||
frequency: float,
|
||||
tx_height: float,
|
||||
rx_height: float
|
||||
) -> float:
|
||||
"""
|
||||
Okumura-Hata path loss model (urban)
|
||||
|
||||
Returns path loss in dB
|
||||
"""
|
||||
"""Okumura-Hata path loss model (urban). Returns path loss in dB."""
|
||||
d_km = distance / 1000
|
||||
|
||||
if d_km < 0.1:
|
||||
d_km = 0.1 # Minimum distance
|
||||
d_km = 0.1
|
||||
|
||||
# Mobile antenna height correction (urban)
|
||||
a_hm = (1.1 * np.log10(frequency) - 0.7) * rx_height - (1.56 * np.log10(frequency) - 0.8)
|
||||
|
||||
# Path loss
|
||||
L = (69.55 + 26.16 * np.log10(frequency) - 13.82 * np.log10(tx_height) - a_hm +
|
||||
(44.9 - 6.55 * np.log10(tx_height)) * np.log10(d_km))
|
||||
|
||||
@@ -393,25 +462,19 @@ class CoverageService:
|
||||
azimuth: float, beamwidth: float
|
||||
) -> float:
|
||||
"""Calculate antenna pattern attenuation"""
|
||||
# Calculate bearing from site to point
|
||||
bearing = self._calculate_bearing(site_lat, site_lon, point_lat, point_lon)
|
||||
|
||||
# Angle difference from main lobe
|
||||
angle_diff = abs(bearing - azimuth)
|
||||
if angle_diff > 180:
|
||||
angle_diff = 360 - angle_diff
|
||||
|
||||
# Simple cosine pattern approximation
|
||||
# 3dB beamwidth = angle where power drops to half
|
||||
half_beamwidth = beamwidth / 2
|
||||
|
||||
if angle_diff <= half_beamwidth:
|
||||
# Within main lobe - minimal loss
|
||||
loss = 3 * (angle_diff / half_beamwidth) ** 2
|
||||
else:
|
||||
# Outside main lobe - significant loss
|
||||
loss = 3 + 12 * ((angle_diff - half_beamwidth) / half_beamwidth) ** 2
|
||||
loss = min(loss, 25) # Cap at 25dB (back lobe)
|
||||
loss = min(loss, 25)
|
||||
|
||||
return loss
|
||||
|
||||
@@ -433,23 +496,12 @@ class CoverageService:
|
||||
return (bearing + 360) % 360
|
||||
|
||||
def _diffraction_loss(self, clearance: float, frequency: float) -> float:
|
||||
"""
|
||||
Knife-edge diffraction loss
|
||||
|
||||
Args:
|
||||
clearance: Clearance in meters (negative = obstructed)
|
||||
frequency: Frequency in MHz
|
||||
|
||||
Returns:
|
||||
Additional loss in dB
|
||||
"""
|
||||
"""Knife-edge diffraction loss. Returns additional loss in dB."""
|
||||
if clearance >= 0:
|
||||
return 0.0 # No obstruction
|
||||
return 0.0
|
||||
|
||||
# Fresnel parameter approximation
|
||||
v = abs(clearance) / 10 # Normalize
|
||||
v = abs(clearance) / 10
|
||||
|
||||
# Knife-edge loss approximation
|
||||
if v <= 0:
|
||||
loss = 0
|
||||
elif v < 2.4:
|
||||
@@ -457,7 +509,7 @@ class CoverageService:
|
||||
else:
|
||||
loss = 13.0 + 20 * np.log10(v)
|
||||
|
||||
return min(loss, 40) # Cap at 40dB
|
||||
return min(loss, 40)
|
||||
|
||||
|
||||
# Singleton
|
||||
|
||||
Reference in New Issue
Block a user