mytec: after methods

This commit is contained in:
2026-02-02 01:55:09 +02:00
parent b5b2fd90d2
commit aa07fb5f02
10 changed files with 495 additions and 101 deletions

View File

@@ -678,33 +678,65 @@ class CoverageService:
return list(point_map.values())
# Adaptive resolution zone boundaries (meters)
_ADAPTIVE_ZONES = [
(0, 2000), # Inner: full user resolution
(2000, 5000), # Middle: at least 300m
(5000, float('inf')), # Outer: at least 500m
]
_ADAPTIVE_MIN_RES = [None, 300, 500] # Minimum resolution per zone
def _generate_grid(
self,
center_lat: float, center_lon: float,
radius: float, resolution: float
) -> List[Tuple[float, float]]:
"""Generate coverage grid points"""
"""Generate coverage grid with adaptive resolution.
Close to TX (<2km): user's chosen resolution (details matter).
Mid-range (2-5km): at least 300m resolution.
Far (>5km): at least 500m resolution.
For small radii or coarse base resolution, this degenerates to a
uniform grid (no zones exceed their minimum).
"""
cos_lat = np.cos(np.radians(center_lat))
seen = set()
points = []
# Convert resolution to degrees
lat_step = resolution / 111000
lon_step = resolution / (111000 * np.cos(np.radians(center_lat)))
for zone_idx, (zone_min_m, zone_max_m) in enumerate(self._ADAPTIVE_ZONES):
if zone_min_m >= radius:
break # No points in this zone
# Calculate grid bounds
lat_delta = radius / 111000
lon_delta = radius / (111000 * np.cos(np.radians(center_lat)))
zone_max_m = min(zone_max_m, radius)
min_res = self._ADAPTIVE_MIN_RES[zone_idx]
zone_res = max(resolution, min_res) if min_res else resolution
lat = center_lat - lat_delta
while lat <= center_lat + lat_delta:
lon = center_lon - lon_delta
while lon <= center_lon + lon_delta:
# Check if within radius (circular, not square)
dist = TerrainService.haversine_distance(center_lat, center_lon, lat, lon)
if dist <= radius:
points.append((lat, lon))
lon += lon_step
lat += lat_step
lat_step = zone_res / 111000
lon_step = zone_res / (111000 * cos_lat)
# Grid bounds for this annular ring (with small overlap at boundaries)
lat_delta = zone_max_m / 111000
lon_delta = zone_max_m / (111000 * cos_lat)
lat = center_lat - lat_delta
while lat <= center_lat + lat_delta:
lon = center_lon - lon_delta
while lon <= center_lon + lon_delta:
dist = TerrainService.haversine_distance(
center_lat, center_lon, lat, lon
)
if zone_min_m <= dist <= zone_max_m:
# Round to avoid floating-point duplicates at zone boundaries
key = (round(lat, 7), round(lon, 7))
if key not in seen:
seen.add(key)
points.append(key)
lon += lon_step
lat += lat_step
_clog(f"Adaptive grid: {len(points)} points "
f"(radius={radius:.0f}m, base_res={resolution:.0f}m)")
return points
def _run_point_loop(
@@ -1051,6 +1083,112 @@ class CoverageService:
"""Knife-edge diffraction loss using ITU-R P.526 model."""
return _DIFFRACTION_MODEL.calculate_clearance_loss(clearance, frequency)
async def calculate_radial_preview(
self,
site: SiteParams,
settings: CoverageSettings,
num_spokes: int = 360,
points_per_spoke: int = 50,
) -> List[CoveragePoint]:
"""Fast radial preview using terrain-only along 360 spokes.
Much faster than full grid because:
- No OSM data fetch (no buildings/vegetation/water)
- Terrain profile reused per spoke
- Fewer total points at long range
"""
calc_start = time.time()
settings = apply_preset(settings)
# Pre-load terrain tiles for bbox
lat_delta = settings.radius / 111000
cos_lat = np.cos(np.radians(site.lat))
lon_delta = settings.radius / (111000 * cos_lat)
min_lat = site.lat - lat_delta
max_lat = site.lat + lat_delta
min_lon = site.lon - lon_delta
max_lon = site.lon + lon_delta
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)
site_elevation = self.terrain.get_elevation_sync(site.lat, site.lon)
# Select propagation model
env = getattr(settings, 'environment', 'urban')
model = select_propagation_model(site.frequency, env)
points: List[CoveragePoint] = []
for angle_deg in range(num_spokes):
angle_rad = math.radians(angle_deg)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Antenna pattern loss for this spoke direction
antenna_loss = 0.0
if site.azimuth is not None and site.beamwidth:
angle_diff = abs(angle_deg - site.azimuth)
if angle_diff > 180:
angle_diff = 360 - angle_diff
half_bw = site.beamwidth / 2
if angle_diff <= half_bw:
antenna_loss = 3 * (angle_diff / half_bw) ** 2
else:
antenna_loss = 3 + 12 * ((angle_diff - half_bw) / half_bw) ** 2
antenna_loss = min(antenna_loss, 25)
for i in range(1, points_per_spoke + 1):
distance = i * (settings.radius / points_per_spoke)
# Move point along bearing
lat_offset = (distance / 111000) * cos_a
lon_offset = (distance / (111000 * cos_lat)) * sin_a
rx_lat = site.lat + lat_offset
rx_lon = site.lon + lon_offset
# Path loss
prop_input = PropagationInput(
frequency_mhz=site.frequency,
distance_m=distance,
tx_height_m=site.height,
rx_height_m=1.5,
environment=env,
)
path_loss = model.calculate(prop_input).path_loss_db
# Terrain LOS check
terrain_loss = 0.0
has_los = True
if settings.use_terrain:
los_result = self.los.check_line_of_sight_sync(
site.lat, site.lon, site.height,
rx_lat, rx_lon, 1.5,
)
has_los = los_result['has_los']
if not has_los:
terrain_loss = self._diffraction_loss(
los_result['clearance'], site.frequency
)
rsrp = (site.power + site.gain - path_loss
- antenna_loss - terrain_loss)
if rsrp >= settings.min_signal:
points.append(CoveragePoint(
lat=rx_lat, lon=rx_lon, rsrp=rsrp,
distance=distance, has_los=has_los,
terrain_loss=terrain_loss, building_loss=0.0,
))
total_time = time.time() - calc_start
_clog(f"Radial preview: {len(points)} points, {num_spokes} spokes × "
f"{points_per_spoke} pts/spoke, {total_time:.1f}s")
return points
# Singleton
coverage_service = CoverageService()

View File

@@ -21,6 +21,7 @@ Usage:
)
"""
import gc
import os
import sys
import subprocess
@@ -450,6 +451,9 @@ def _calculate_with_ray(
log_fn(f"Ray done: {calc_time:.1f}s, {len(all_results)} results "
f"({calc_time / max(1, total_points) * 1000:.1f}ms/point)")
# Force garbage collection after Ray computation
gc.collect()
timing = {
"parallel_total": calc_time,
"ray_put": put_time,
@@ -744,6 +748,8 @@ def _calculate_with_process_pool(
block.unlink()
except Exception:
pass
# Force garbage collection to release memory from workers
gc.collect()
calc_time = time.time() - t_calc
log_fn(f"ProcessPool done: {calc_time:.1f}s, {len(all_results)} results "
@@ -820,6 +826,9 @@ def _calculate_sequential(
log_fn(f"Sequential done: {calc_time:.1f}s, {len(results)} results "
f"({calc_time / max(1, total) * 1000:.1f}ms/point)")
# Force garbage collection after sequential computation
gc.collect()
timing["sequential_total"] = calc_time
timing["backend"] = "sequential"
return results, timing