diff --git a/REFACTORING_REPORT.md b/REFACTORING_REPORT.md index aa5b5fa..f6b380d 100644 --- a/REFACTORING_REPORT.md +++ b/REFACTORING_REPORT.md @@ -31,7 +31,7 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes | 3 | `src/components/Map/LinkAnalysisPanel.jsx` | 643 | HIGH | Pending | | 4 | `src/components/Map/UI/SiteAnalysisResultsPanel.jsx` | 609 | HIGH | Pending | | 5 | `src/components/Map/OptimizationLayer.jsx` | 517 | HIGH | Pending | -| 6 | `rf-engine/server.py` | 475 | HIGH | Pending | +| 6 | `rf-engine/server.py` | 475 | HIGH | **REFACTORED** | | 7 | `src/components/Map/UI/NodeManager.jsx` | 440 | MEDIUM | Pending | | 8 | `src/components/Map/OptimizationResultsPanel.jsx` | 435 | MEDIUM | Pending | | 9 | `src/components/Map/LinkLayer.jsx` | 429 | MEDIUM | Pending | @@ -39,7 +39,7 @@ MeshRF is a full-stack RF propagation and link analysis application for LoRa mes | 11 | `src/utils/rfMath.js` | 366 | LOW | Pending | | 12 | `src/components/Map/BatchNodesPanel.jsx` | 354 | MEDIUM | Pending | | 13 | `src/hooks/useViewshedTool.js` | 343 | MEDIUM | Pending | -| 14 | `rf-engine/tile_manager.py` | 334 | MEDIUM | Pending | +| 14 | `rf-engine/tile_manager.py` | 334 | MEDIUM | **REFACTORED** | | 15 | `src/components/Map/BatchProcessing.jsx` | 321 | LOW | Pending | | 16 | `src/components/Map/UI/GuidanceOverlays.jsx` | 318 | LOW | Pending | | 17 | `src/context/RFContext.jsx` | 307 | MEDIUM | **REFACTORED** (Facade) | @@ -161,27 +161,17 @@ src/components/Map/ #### 6. `rf-engine/server.py` — 475 lines -**What it does**: Main FastAPI application with endpoints for link analysis, elevation lookups, terrain tile serving, and async Celery task management. +**Status**: Refactored (Phase 2) +- **Extracted Routers**: + - `routers/analysis.py`: Link analysis endpoint. + - `routers/elevation.py`: Elevation and tile endpoints. + - `routers/tasks.py`: Async task management. + - `routers/optimization.py`: Optimization and export endpoints. +- **Shared Dependencies**: + - `dependencies.py`: Handles Redis, TileManager, and Limiter instances. +- **Result**: `server.py` is now a minimal entry point focusing on app setup and middleware. -**Logical sections**: -1. App setup, CORS, rate limiting (lines 1–45) -2. Pydantic request models (lines 44–75) -3. Link analysis endpoints (lines 71–137) -4. Elevation endpoints (lines 138–228) -5. Async task endpoints (lines 231–475) - -**Suggested split**: - -``` -rf-engine/ -├── server.py (~80 lines) — app creation, middleware, router registration -├── schemas.py (~80 lines) — all Pydantic models (consolidate existing) -├── dependencies.py (~50 lines) — Redis, tile_manager DI -└── routers/ - ├── analysis.py (~120 lines) — link analysis endpoints - ├── elevation.py (~100 lines) — elevation + tile serving - └── tasks.py (~150 lines) — async task submission and polling -``` +--- --- @@ -258,17 +248,14 @@ rf-engine/ #### 11. `rf-engine/tile_manager.py` — 334 lines -**What it does**: High-performance elevation tile caching with request coalescing, thread pool management, Redis TTL caching, and interpolation. +**Status**: Refactored (Phase 2) +- **Extracted Components**: + - `rf-engine/cache_layer.py`: Encapsulates Redis caching operations. + - `rf-engine/elevation_client.py`: Manages OpenTopoData API interactions and retries. + - `rf-engine/grid_processor.py`: Contains static methods for grid interpolation and elevation extraction. +- **Result**: `TileManager` is now a clean orchestrator class. -**Suggested split**: - -``` -rf-engine/ -├── tile_manager.py (~120 lines) — orchestration -├── cache_layer.py (~80 lines) — Redis caching logic -├── elevation_client.py (~80 lines) — API fetch implementation -└── grid_processor.py (~80 lines) — interpolation and grid ops -``` +--- --- @@ -323,15 +310,14 @@ src/hooks/ --- -### Phase 2 — Backend API Structure (NEXT) +### Phase 2 — Backend API Structure (COMPLETED) -Reorganize `server.py` into FastAPI routers — this is low-risk since Python imports are explicit and easy to verify: +3. **server.py** (475 → ~80 lines): Refactored into `routers/` directory with `analysis.py`, `elevation.py`, `tasks.py`, `optimization.py`. +4. **tile_manager.py** (334 → ~120 lines): Extracted `cache_layer.py`, `elevation_client.py`, `grid_processor.py`. -3. **server.py** (475 → ~80 lines): Create `routers/` directory with `analysis.py`, `elevation.py`, `tasks.py`. -4. **tile_manager.py** (334 → ~120 lines): Extract `cache_layer.py`, `elevation_client.py`, `grid_processor.py`. +**Status**: Verified with tests and import checks. -**Expected effort**: 1–2 days -**Risk**: Low — FastAPI router pattern is well-defined. +--- --- diff --git a/rf-engine/cache_layer.py b/rf-engine/cache_layer.py new file mode 100644 index 0000000..ce421e7 --- /dev/null +++ b/rf-engine/cache_layer.py @@ -0,0 +1,22 @@ +import msgpack +import redis + +class CacheLayer: + """ + Handles Redis caching operations for elevation tiles. + """ + def __init__(self, redis_client: redis.Redis, ttl: int = 30 * 24 * 60 * 60): + self.redis = redis_client + self.ttl = ttl + + def get_tile(self, key: str): + """Retrieves a tile from Redis cache.""" + packed = self.redis.get(key) + if packed: + return msgpack.unpackb(packed) + return None + + def cache_tile(self, key: str, data: dict): + """Stores a tile in Redis cache with TTL.""" + packed = msgpack.packb(data) + self.redis.setex(key, self.ttl, packed) diff --git a/rf-engine/dependencies.py b/rf-engine/dependencies.py new file mode 100644 index 0000000..2ed5229 --- /dev/null +++ b/rf-engine/dependencies.py @@ -0,0 +1,24 @@ +import os +import redis +from tile_manager import TileManager +from optimization_service import OptimizationService +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) + +REDIS_HOST = os.environ.get("REDIS_HOST", "redis") +REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "changeme") + +# Initialize Redis Client +redis_client = redis.Redis( + host=REDIS_HOST, + port=REDIS_PORT, + db=0, + password=REDIS_PASSWORD +) + +# Initialize Core Services +tile_manager = TileManager(redis_client) +optimization_service = OptimizationService(tile_manager) diff --git a/rf-engine/elevation_client.py b/rf-engine/elevation_client.py new file mode 100644 index 0000000..1dc1dad --- /dev/null +++ b/rf-engine/elevation_client.py @@ -0,0 +1,111 @@ +import os +import requests +import numpy as np +import logging +import mercantile +from concurrent.futures import ThreadPoolExecutor, TimeoutError +from requests.adapters import HTTPAdapter + +logger = logging.getLogger(__name__) + +class ElevationClient: + """ + Handles interactions with OpenTopoData API for elevation tiles. + """ + def __init__(self, max_workers=30): + # Connection pooling for high concurrency + self.session = requests.Session() + adapter = HTTPAdapter(pool_connections=50, pool_maxsize=50) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + self.executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='elev_client_') + + self.base_url = os.environ.get('ELEVATION_API_URL', 'http://opentopodata:5000') + self.dataset = os.environ.get('ELEVATION_DATASET', 'srtm30m') + + def fetch_tile(self, x, y, z): + """ + Fetch elevation data from OpenTopoData API. + """ + bounds = mercantile.bounds(x, y, z) + lat_min, lat_max = bounds.south, bounds.north + lon_min, lon_max = bounds.west, bounds.east + + # Create 16x16 grid of coordinates + lats = np.linspace(lat_min, lat_max, 16) + lons = np.linspace(lon_min, lon_max, 16) + + lat_grid, lon_grid = np.meshgrid(lats, lons) + lat_flat = lat_grid.flatten() + lon_flat = lon_grid.flatten() + + # OpenTopoData supports up to 100 locations per request + # We have 256 points (16x16), so split into 3 batches: 100, 100, 56 + batch_size = 100 + + batches = [] + for i in range(0, len(lat_flat), batch_size): + batch_lats = lat_flat[i:i + batch_size] + batch_lons = lon_flat[i:i + batch_size] + locations = "|".join([f"{lat},{lon}" for lat, lon in zip(batch_lats, batch_lons)]) + batches.append(locations) + + def fetch_batch_task(locations, batch_num): + try: + url = f"{self.base_url}/v1/{self.dataset}" + response = self.session.get( + url, + params={'locations': locations}, + timeout=10 + ) + + if response.status_code == 200: + data = response.json() + if data.get('status') == 'OK' and 'results' in data: + return [result.get('elevation', 0.0) for result in data['results']] + else: + error_msg = data.get('error', 'Unknown error') + logger.error(f"OpenTopoData batch {batch_num} error: {error_msg}") + return None + elif response.status_code == 404: + logger.error(f"Dataset '{self.dataset}' not found. Check ELEVATION_DATASET env var and data files.") + return None + else: + logger.warning(f"OpenTopoData batch {batch_num} failed with status {response.status_code}") + return None + + except requests.exceptions.Timeout: + logger.error(f"OpenTopoData request timed out for batch {batch_num}") + return None + except requests.exceptions.ConnectionError: + logger.error(f"Cannot connect to OpenTopoData at {self.base_url}. Is the container running?") + return None + except Exception as e: + logger.error(f"Exception fetching OpenTopoData batch {batch_num}: {e}") + return None + + # Execute batches in parallel + futures = [self.executor.submit(fetch_batch_task, locs, i) for i, locs in enumerate(batches)] + + all_elevations = [] + for future in futures: + try: + batch_result = future.result(timeout=30) + except (TimeoutError, Exception) as e: + logger.error(f"Tile fetch timed out or failed: {e}") + return None + if batch_result is None: + return None + all_elevations.extend(batch_result) + + if len(all_elevations) == 256: + logger.info(f"Successfully fetched elevation data from OpenTopoData ({self.dataset}): min={min(all_elevations):.1f}m, max={max(all_elevations):.1f}m") + return {"elevation": all_elevations} + else: + logger.error(f"Expected 256 elevation points, got {len(all_elevations)}") + return None + + def shutdown(self): + self.executor.shutdown(wait=False) + self.session.close() diff --git a/rf-engine/grid_processor.py b/rf-engine/grid_processor.py new file mode 100644 index 0000000..8ac3c12 --- /dev/null +++ b/rf-engine/grid_processor.py @@ -0,0 +1,77 @@ +import numpy as np +import scipy.ndimage +import mercantile + +class GridProcessor: + """ + Handles grid interpolation and elevation extraction logic. + """ + + @staticmethod + def get_interpolated_grid(tile_data, size=256): + """ + Returns a (size, size) numpy array of elevation data for the tile. + Upscales the low-res 16x16 fetched data. + """ + if not tile_data or 'elevation' not in tile_data: + return np.zeros((size, size)) + + raw_elev = np.array(tile_data['elevation']) + if raw_elev.size != 16*16: + return np.zeros((size, size)) + + grid_16 = raw_elev.reshape((16, 16)).T + grid_16 = np.flipud(grid_16) + + zoom_factor = size / 16.0 + high_res_grid = scipy.ndimage.zoom(grid_16, zoom_factor, order=1) + + return high_res_grid + + @staticmethod + def extract_elevation_from_tile(tile_data, lat, lon, tile): + """ + Performs bilinear interpolation on the 16x16 grid to find elevation at lat, lon. + """ + if not tile_data or 'elevation' not in tile_data: + return 0.0 + + raw_elev = np.array(tile_data['elevation']) + if raw_elev.size != 256: + return 0.0 + + grid = raw_elev.reshape((16, 16)) + + bounds = mercantile.bounds(tile) + lat_min, lat_max = bounds.south, bounds.north + lon_min, lon_max = bounds.west, bounds.east + + if lat_max == lat_min or lon_max == lon_min: + return 0.0 + + u = (lat - lat_min) / (lat_max - lat_min) * 15.0 + v = (lon - lon_min) / (lon_max - lon_min) * 15.0 + + u = np.clip(u, 0, 15) + v = np.clip(v, 0, 15) + + i = int(np.floor(u)) + j = int(np.floor(v)) + + u_ratio = u - i + v_ratio = v - j + + i_next = min(i + 1, 15) + j_next = min(j + 1, 15) + + p00 = grid[j, i] + p10 = grid[j, i_next] + p01 = grid[j_next, i] + p11 = grid[j_next, i_next] + + val_j = (p00 * (1 - u_ratio)) + (p10 * u_ratio) + val_jnext = (p01 * (1 - u_ratio)) + (p11 * u_ratio) + + final_elev = (val_j * (1 - v_ratio)) + (val_jnext * v_ratio) + + return float(final_elev) diff --git a/rf-engine/routers/__init__.py b/rf-engine/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rf-engine/routers/analysis.py b/rf-engine/routers/analysis.py new file mode 100644 index 0000000..ff28830 --- /dev/null +++ b/rf-engine/routers/analysis.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter +from pydantic import BaseModel, field_validator +import rf_physics +from dependencies import tile_manager + +router = APIRouter() + +class LinkRequest(BaseModel): + tx_lat: float + tx_lon: float + rx_lat: float + rx_lon: float + frequency_mhz: float + tx_height: float + rx_height: float + model: str = "bullington" # bullington, fspl + environment: str = "suburban" + k_factor: float = 1.333 + clutter_height: float = 0.0 + + @field_validator('tx_lat', 'rx_lat') + @classmethod + def validate_lat(cls, v): + if not -90 <= v <= 90: + raise ValueError('Latitude must be between -90 and 90') + return v + + @field_validator('tx_lon', 'rx_lon') + @classmethod + def validate_lon(cls, v): + if not -180 <= v <= 180: + raise ValueError('Longitude must be between -180 and 180') + return v + +@router.post("/calculate-link") +def calculate_link_endpoint(req: LinkRequest): + """ + Synchronous endpoint for real-time link analysis. + Uses cached TileManager to fetch elevation profile. + """ + # Calculate distance between points + dist_m = rf_physics.haversine_distance( + req.tx_lat, req.tx_lon, + req.rx_lat, req.rx_lon + ) + + # Get elevation profile along path + elevs = tile_manager.get_elevation_profile( + req.tx_lat, req.tx_lon, + req.rx_lat, req.rx_lon, + samples=100 # Increased samples for ITM accuracy + ) + + # Calculate Path Loss (ITM or FSPL) + # Calculate Path Loss (Generic Dispatcher) + path_loss_db = rf_physics.calculate_path_loss( + dist_m, + elevs, + req.frequency_mhz, + req.tx_height, + req.rx_height, + model=req.model, + environment=req.environment, + k_factor=req.k_factor, + clutter_height=req.clutter_height + ) + + # Analyze link with correct signature + result = rf_physics.analyze_link( + elevs, + dist_m, + req.frequency_mhz, + req.tx_height, + req.rx_height, + k_factor=req.k_factor, + clutter_height=req.clutter_height + ) + + result['path_loss_db'] = float(path_loss_db) + result['model_used'] = req.model + + return result diff --git a/rf-engine/routers/elevation.py b/rf-engine/routers/elevation.py new file mode 100644 index 0000000..e9f05eb --- /dev/null +++ b/rf-engine/routers/elevation.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter +from pydantic import BaseModel, field_validator +from starlette.requests import Request +from starlette.responses import Response +from dependencies import tile_manager, limiter +import io +import numpy as np +from PIL import Image + +router = APIRouter() + +class ElevationRequest(BaseModel): + lat: float + lon: float + + @field_validator('lat') + @classmethod + def validate_lat(cls, v): + if not -90 <= v <= 90: + raise ValueError('Latitude must be between -90 and 90') + return v + + @field_validator('lon') + @classmethod + def validate_lon(cls, v): + if not -180 <= v <= 180: + raise ValueError('Longitude must be between -180 and 180') + return v + +@router.post("/get-elevation") +def get_elevation_endpoint(req: ElevationRequest): + """ + Get elevation for a single point. + """ + elevation = tile_manager.get_elevation(req.lat, req.lon) + return {"elevation": elevation} + + +class BatchElevationRequest(BaseModel): + locations: str # Pipe-separated "lat,lng|lat,lng|..." + dataset: str = "ned10m" + +@router.post("/elevation-batch") +@limiter.limit("30/minute") +def get_batch_elevation(req: BatchElevationRequest, request: Request): + """ + Batch elevation lookup for frontend path profiles. + Used for optimized path profiles. + """ + try: + # Parse locations + coords = [] + for loc in req.locations.split('|'): + if not loc.strip(): continue + parts = loc.split(',') + if len(parts) == 2: + lat, lng = map(float, parts) + coords.append((lat, lng)) + + # Fetch elevations in parallel + elevs = tile_manager.get_elevations_batch(coords) + + results = [] + for i, (lat, lon) in enumerate(coords): + results.append({ + "elevation": elevs[i], + "location": {"lat": lat, "lng": lon} + }) + + return { + "status": "OK", + "results": results + } + except ValueError as e: + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=400, + content={"status": "INVALID_REQUEST", "error": str(e)} + ) + except Exception as e: + from fastapi.responses import JSONResponse + import logging + logging.getLogger(__name__).error(f"Internal error: {e}", exc_info=True) + return JSONResponse( + status_code=500, + content={"status": "SERVER_ERROR", "error": "Internal server error"} + ) + +@router.get("/tiles/{z}/{x}/{y}.png") +def get_elevation_tile(z: int, x: int, y: int): + """ + Serve elevation data as Terrain-RGB tiles. + Format: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) + """ + grid = tile_manager.get_interpolated_grid(x, y, z, size=256) + + # Encode to Terrain-RGB format + # h = -10000 + (v * 0.1) => v = (h + 10000) * 10 + h_scaled = (grid + 10000) * 10 + h_scaled = np.clip(h_scaled, 0, 16777215) # Clip to 24-bit max + h_scaled = h_scaled.astype(np.uint32) + + r = (h_scaled >> 16) & 0xFF + g = (h_scaled >> 8) & 0xFF + b = h_scaled & 0xFF + + rgb = np.stack((r, g, b), axis=-1).astype(np.uint8) + + img = Image.fromarray(rgb, mode='RGB') + buf = io.BytesIO() + img.save(buf, format='PNG') + buf.seek(0) + + return Response(content=buf.getvalue(), media_type="image/png") diff --git a/rf-engine/routers/optimization.py b/rf-engine/routers/optimization.py new file mode 100644 index 0000000..8a1443a --- /dev/null +++ b/rf-engine/routers/optimization.py @@ -0,0 +1,173 @@ +from fastapi import APIRouter +from pydantic import BaseModel, field_validator +from typing import Optional, List +from starlette.requests import Request +from starlette.responses import Response +from dependencies import tile_manager, optimization_service, limiter +import rf_physics + +router = APIRouter() + +class OptimizeRequest(BaseModel): + min_lat: float + min_lon: float + max_lat: float + max_lon: float + frequency_mhz: float + tx_height: float + rx_height: float = 2.0 + k_factor: float = 1.333 + clutter_height: float = 0.0 + return_heatmap: bool = False + weights: dict = {"elevation": 0.5, "prominence": 0.3, "fresnel": 0.2} + existing_nodes: list = [] # List of {lat, lon, height} + + @field_validator('min_lat', 'max_lat') + @classmethod + def validate_lat(cls, v): + if not -90 <= v <= 90: + raise ValueError('Latitude must be between -90 and 90') + return v + + @field_validator('min_lon', 'max_lon') + @classmethod + def validate_lon(cls, v): + if not -180 <= v <= 180: + raise ValueError('Longitude must be between -180 and 180') + return v + +@router.post("/optimize-location") +@limiter.limit("10/minute") +def optimize_location_endpoint(req: OptimizeRequest, request: Request): + """ + Find best location using multi-criteria analysis (elevation, prominence, fresnel). + """ + try: + # Adaptive Grid + # Calculate dimensions in km + dist_lat_km = rf_physics.haversine_distance(req.min_lat, req.min_lon, req.max_lat, req.min_lon) / 1000.0 + dist_lon_km = rf_physics.haversine_distance(req.min_lat, req.min_lon, req.min_lat, req.max_lon) / 1000.0 + + # Target resolution: 150m (0.15 km) + target_res_km = 0.15 + + steps_lat = int(dist_lat_km / target_res_km) + steps_lon = int(dist_lon_km / target_res_km) + + # Safety Caps (Min 10, Max 50 -> 2500 points max) + steps_lat = max(10, min(50, steps_lat)) + steps_lon = max(10, min(50, steps_lon)) + + lat_step = (req.max_lat - req.min_lat) / steps_lat + lon_step = (req.max_lon - req.min_lon) / steps_lon + + coords = [] + for i in range(steps_lat + 1): + for j in range(steps_lon + 1): + lat = req.min_lat + (i * lat_step) + lon = req.min_lon + (j * lon_step) + coords.append((lat, lon)) + + # Batch fetch elevations + elevs = tile_manager.get_elevations_batch(coords) + + candidates = [] + for i, (lat, lon) in enumerate(coords): + # Basic Candidate + cand = { + "lat": lat, + "lon": lon, + "elevation": elevs[i] + } + # Score Components + metrics = optimization_service.score_candidate( + cand, + req.weights, + req.existing_nodes, + tx_height=req.tx_height, + rx_height=req.rx_height, + freq_mhz=req.frequency_mhz, + k_factor=req.k_factor, + clutter_height=req.clutter_height + ) + cand.update(metrics) # Adds prominence, fresnel + candidates.append(cand) + + # Normalize and Calculate Final Score + if not candidates: + return {"status": "success", "locations": []} + + max_elev = max([c['elevation'] for c in candidates]) or 1.0 + max_prom = max([c['prominence'] for c in candidates]) or 1.0 + # Fresnel is already 0-1 + + w_elev = req.weights.get("elevation", 0.3) + w_prom = req.weights.get("prominence", 0.4) + w_fres = req.weights.get("fresnel", 0.3) + + for c in candidates: + norm_elev = c['elevation'] / max_elev if max_elev > 0 else 0 + norm_prom = c['prominence'] / max_prom if max_prom > 0 else 0 + + c['score'] = (norm_elev * w_elev) + (norm_prom * w_prom) + (c['fresnel'] * w_fres) + # Scale to 0-100 for display + c['score'] = round(c['score'] * 100, 1) + + # Sort by Score desc + candidates.sort(key=lambda x: x["score"], reverse=True) + + # Take top 5 for "Ghost Nodes" + top_results = candidates[:5] + + response = { + "status": "success", + "locations": top_results, + "metadata": { + "max_elevation": max_elev, + "max_prominence": max_prom + } + } + + if req.return_heatmap: + # Send simplified data for heatmap (lat, lon, score) + # To save bandwidth, maybe round lat/lon? + heatmap_data = [ + {"lat": round(c['lat'], 5), "lon": round(c['lon'], 5), "score": c['score']} + for c in candidates + ] + response["heatmap"] = heatmap_data + + return response + except Exception as e: + print(f"Optimize Error: {e}") + from fastapi.responses import JSONResponse + return JSONResponse( + status_code=500, + content={"status": "error", "message": f"Server Error: {str(e)}"} + ) + +class ExportRequest(BaseModel): + locations: list + format: str = "csv" # csv, kml + +@router.post("/export-results") +def export_results_endpoint(req: ExportRequest): + """ + Generate export file for site candidates. + """ + from api.export import generate_csv, generate_kml + + if req.format == "kml": + content = generate_kml(req.locations) + media_type = "application/vnd.google-earth.kml+xml" + filename = "rf_scan_results.kml" + else: + content = generate_csv(req.locations) + media_type = "text/csv" + filename = "rf_scan_results.csv" + + return Response( + content=content, + media_type=media_type, + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) diff --git a/rf-engine/routers/tasks.py b/rf-engine/routers/tasks.py new file mode 100644 index 0000000..fd063dc --- /dev/null +++ b/rf-engine/routers/tasks.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter +from pydantic import BaseModel, field_validator +from typing import Optional +from starlette.requests import Request +from dependencies import limiter +from models import NodeConfig +from worker import celery_app +from sse_starlette.sse import EventSourceResponse +from celery.result import AsyncResult +import json +import asyncio + +router = APIRouter() + +class ScanRequest(BaseModel): + nodes: list[NodeConfig] + radius: float = 5000.0 + optimize_n: Optional[int] = None + frequency_mhz: float = 915.0 + rx_height: float = 2.0 + k_factor: float = 1.333 + clutter_height: float = 0.0 + + @field_validator('radius') + @classmethod + def validate_radius(cls, v): + if not 100 <= v <= 50000: + raise ValueError('Radius must be between 100 and 50000 meters') + return v + +@router.post("/scan/start") +@limiter.limit("5/minute") +def start_scan_endpoint(req: ScanRequest, request: Request): + """ + Start asynchronous batch viewshed scan (Celery). + """ + from tasks.viewshed import calculate_batch_viewshed + + if not req.nodes: + return {"status": "error", "message": "No nodes provided"} + + # Start Celery Task + task = calculate_batch_viewshed.delay({ + "nodes": [n.model_dump() for n in req.nodes], # Convert Pydantic models to dicts + "options": { + "radius": req.radius, + "optimize_n": req.optimize_n, + "frequency_mhz": req.frequency_mhz, + "rx_height": req.rx_height, + "k_factor": req.k_factor, + "clutter_height": req.clutter_height + } + }) + + return {"status": "started", "task_id": task.id} + + +@router.get("/task_status/{task_id}") +async def task_status_endpoint(task_id: str): + """ + SSE Endpoint for Task Progress. + """ + async def event_generator(): + task = AsyncResult(task_id, app=celery_app) + max_polls = 600 # 5 minutes at 0.5s + polls = 0 + while polls < max_polls: + polls += 1 + if task.state == 'PENDING': + yield json.dumps({"event": "progress", "data": {"progress": 0}}) + elif task.state == 'PROGRESS': + meta = task.info or {} + yield json.dumps({"event": "progress", "data": meta}) + elif task.state == 'SUCCESS': + yield json.dumps({"event": "complete", "data": task.result}) + return + elif task.state == 'FAILURE': + yield json.dumps({"event": "error", "data": str(task.info)}) + return + + await asyncio.sleep(0.5) + yield json.dumps({"event": "error", "data": "Task timed out after 5 minutes"}) + + return EventSourceResponse(event_generator()) diff --git a/rf-engine/server.py b/rf-engine/server.py index 538fe49..8bba1be 100644 --- a/rf-engine/server.py +++ b/rf-engine/server.py @@ -1,20 +1,11 @@ from fastapi import FastAPI -from pydantic import BaseModel -from typing import Optional -import io -from starlette.responses import Response -from PIL import Image -import numpy as np -import mercantile -import os from fastapi.middleware.cors import CORSMiddleware -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address +from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded -from starlette.requests import Request -from pydantic import field_validator -limiter = Limiter(key_func=get_remote_address) +from dependencies import limiter +from routers import analysis, elevation, tasks, optimization + app = FastAPI(title="MeshRF Engine") app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @@ -27,449 +18,12 @@ allow_headers=["*"], ) -# --- Dependencies --- -import redis -from tile_manager import TileManager -import rf_physics -from optimization_service import OptimizationService - -# --- Initialization --- -REDIS_HOST = os.environ.get("REDIS_HOST", "redis") -REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) -REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "changeme") -redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0, password=REDIS_PASSWORD) -tile_manager = TileManager(redis_client) -optimization_service = OptimizationService(tile_manager) - -class LinkRequest(BaseModel): - tx_lat: float - tx_lon: float - rx_lat: float - rx_lon: float - frequency_mhz: float - tx_height: float - rx_height: float - model: str = "bullington" # bullington, fspl - environment: str = "suburban" - k_factor: float = 1.333 - clutter_height: float = 0.0 - - @field_validator('tx_lat', 'rx_lat') - @classmethod - def validate_lat(cls, v): - if not -90 <= v <= 90: - raise ValueError('Latitude must be between -90 and 90') - return v - - @field_validator('tx_lon', 'rx_lon') - @classmethod - def validate_lon(cls, v): - if not -180 <= v <= 180: - raise ValueError('Longitude must be between -180 and 180') - return v - -@app.post("/calculate-link") -def calculate_link_endpoint(req: LinkRequest): - """ - Synchronous endpoint for real-time link analysis. - Uses cached TileManager to fetch elevation profile. - """ - # Calculate distance between points - dist_m = rf_physics.haversine_distance( - req.tx_lat, req.tx_lon, - req.rx_lat, req.rx_lon - ) - - # Get elevation profile along path - elevs = tile_manager.get_elevation_profile( - req.tx_lat, req.tx_lon, - req.rx_lat, req.rx_lon, - samples=100 # Increased samples for ITM accuracy - ) - - # Calculate Path Loss (ITM or FSPL) - # Calculate Path Loss (Generic Dispatcher) - path_loss_db = rf_physics.calculate_path_loss( - dist_m, - elevs, - req.frequency_mhz, - req.tx_height, - req.rx_height, - model=req.model, - environment=req.environment, - k_factor=req.k_factor, - clutter_height=req.clutter_height - ) - - # Analyze link with correct signature - result = rf_physics.analyze_link( - elevs, - dist_m, - req.frequency_mhz, - req.tx_height, - req.rx_height, - k_factor=req.k_factor, - clutter_height=req.clutter_height - ) - - result['path_loss_db'] = float(path_loss_db) - result['model_used'] = req.model - - return result - -class ElevationRequest(BaseModel): - lat: float - lon: float - - @field_validator('lat') - @classmethod - def validate_lat(cls, v): - if not -90 <= v <= 90: - raise ValueError('Latitude must be between -90 and 90') - return v - - @field_validator('lon') - @classmethod - def validate_lon(cls, v): - if not -180 <= v <= 180: - raise ValueError('Longitude must be between -180 and 180') - return v - -@app.post("/get-elevation") -def get_elevation_endpoint(req: ElevationRequest): - """ - Get elevation for a single point. - """ - elevation = tile_manager.get_elevation(req.lat, req.lon) - return {"elevation": elevation} - - -class BatchElevationRequest(BaseModel): - locations: str # Pipe-separated "lat,lng|lat,lng|..." - dataset: str = "ned10m" - -@app.post("/elevation-batch") -@limiter.limit("30/minute") -def get_batch_elevation(req: BatchElevationRequest, request: Request): - """ - Batch elevation lookup for frontend path profiles. - Used for optimized path profiles. - """ - try: - # Parse locations - coords = [] - for loc in req.locations.split('|'): - if not loc.strip(): continue - parts = loc.split(',') - if len(parts) == 2: - lat, lng = map(float, parts) - coords.append((lat, lng)) - - # Fetch elevations in parallel - elevs = tile_manager.get_elevations_batch(coords) - - results = [] - for i, (lat, lon) in enumerate(coords): - results.append({ - "elevation": elevs[i], - "location": {"lat": lat, "lng": lon} - }) - - return { - "status": "OK", - "results": results - } - except ValueError as e: - from fastapi.responses import JSONResponse - return JSONResponse( - status_code=400, - content={"status": "INVALID_REQUEST", "error": str(e)} - ) - except Exception as e: - from fastapi.responses import JSONResponse - import logging - logging.getLogger(__name__).error(f"Internal error: {e}", exc_info=True) - return JSONResponse( - status_code=500, - content={"status": "SERVER_ERROR", "error": "Internal server error"} - ) - +# Include Routers +app.include_router(analysis.router) +app.include_router(elevation.router) +app.include_router(tasks.router) +app.include_router(optimization.router) @app.get("/health") def health_check(): return {"status": "ok"} - -@app.get("/tiles/{z}/{x}/{y}.png") -def get_elevation_tile(z: int, x: int, y: int): - """ - Serve elevation data as Terrain-RGB tiles. - Format: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) - """ - grid = tile_manager.get_interpolated_grid(x, y, z, size=256) - - # Encode to Terrain-RGB format - # h = -10000 + (v * 0.1) => v = (h + 10000) * 10 - h_scaled = (grid + 10000) * 10 - h_scaled = np.clip(h_scaled, 0, 16777215) # Clip to 24-bit max - h_scaled = h_scaled.astype(np.uint32) - - r = (h_scaled >> 16) & 0xFF - g = (h_scaled >> 8) & 0xFF - b = h_scaled & 0xFF - - rgb = np.stack((r, g, b), axis=-1).astype(np.uint8) - - img = Image.fromarray(rgb, mode='RGB') - buf = io.BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - - return Response(content=buf.getvalue(), media_type="image/png") - - - -# --- Async Task Endpoints --- - -from models import NodeConfig - -class ScanRequest(BaseModel): - nodes: list[NodeConfig] - radius: float = 5000.0 - optimize_n: Optional[int] = None - frequency_mhz: float = 915.0 - rx_height: float = 2.0 - k_factor: float = 1.333 - clutter_height: float = 0.0 - - @field_validator('radius') - @classmethod - def validate_radius(cls, v): - if not 100 <= v <= 50000: - raise ValueError('Radius must be between 100 and 50000 meters') - return v - -@app.post("/scan/start") -@limiter.limit("5/minute") -def start_scan_endpoint(req: ScanRequest, request: Request): - """ - Start asynchronous batch viewshed scan (Celery). - """ - from tasks.viewshed import calculate_batch_viewshed - - if not req.nodes: - return {"status": "error", "message": "No nodes provided"} - - # Start Celery Task - task = calculate_batch_viewshed.delay({ - "nodes": [n.model_dump() for n in req.nodes], # Convert Pydantic models to dicts - "options": { - "radius": req.radius, - "optimize_n": req.optimize_n, - "frequency_mhz": req.frequency_mhz, - "rx_height": req.rx_height, - "k_factor": req.k_factor, - "clutter_height": req.clutter_height - } - }) - - return {"status": "started", "task_id": task.id} - - -@app.get("/task_status/{task_id}") -async def task_status_endpoint(task_id: str): - """ - SSE Endpoint for Task Progress. - """ - from sse_starlette.sse import EventSourceResponse - from celery.result import AsyncResult - from worker import celery_app - import json - import asyncio - - async def event_generator(): - task = AsyncResult(task_id, app=celery_app) - max_polls = 600 # 5 minutes at 0.5s - polls = 0 - while polls < max_polls: - polls += 1 - if task.state == 'PENDING': - yield json.dumps({"event": "progress", "data": {"progress": 0}}) - elif task.state == 'PROGRESS': - meta = task.info or {} - yield json.dumps({"event": "progress", "data": meta}) - elif task.state == 'SUCCESS': - yield json.dumps({"event": "complete", "data": task.result}) - return - elif task.state == 'FAILURE': - yield json.dumps({"event": "error", "data": str(task.info)}) - return - - await asyncio.sleep(0.5) - yield json.dumps({"event": "error", "data": "Task timed out after 5 minutes"}) - - return EventSourceResponse(event_generator()) - - -class OptimizeRequest(BaseModel): - min_lat: float - min_lon: float - max_lat: float - max_lon: float - frequency_mhz: float - tx_height: float - rx_height: float = 2.0 - k_factor: float = 1.333 - clutter_height: float = 0.0 - return_heatmap: bool = False - weights: dict = {"elevation": 0.5, "prominence": 0.3, "fresnel": 0.2} - existing_nodes: list = [] # List of {lat, lon, height} - - @field_validator('min_lat', 'max_lat') - @classmethod - def validate_lat(cls, v): - if not -90 <= v <= 90: - raise ValueError('Latitude must be between -90 and 90') - return v - - @field_validator('min_lon', 'max_lon') - @classmethod - def validate_lon(cls, v): - if not -180 <= v <= 180: - raise ValueError('Longitude must be between -180 and 180') - return v - -@app.post("/optimize-location") -@limiter.limit("10/minute") -def optimize_location_endpoint(req: OptimizeRequest, request: Request): - """ - Find best location using multi-criteria analysis (elevation, prominence, fresnel). - """ - try: - # Adaptive Grid - # Calculate dimensions in km - dist_lat_km = rf_physics.haversine_distance(req.min_lat, req.min_lon, req.max_lat, req.min_lon) / 1000.0 - dist_lon_km = rf_physics.haversine_distance(req.min_lat, req.min_lon, req.min_lat, req.max_lon) / 1000.0 - - # Target resolution: 150m (0.15 km) - target_res_km = 0.15 - - steps_lat = int(dist_lat_km / target_res_km) - steps_lon = int(dist_lon_km / target_res_km) - - # Safety Caps (Min 10, Max 50 -> 2500 points max) - steps_lat = max(10, min(50, steps_lat)) - steps_lon = max(10, min(50, steps_lon)) - - lat_step = (req.max_lat - req.min_lat) / steps_lat - lon_step = (req.max_lon - req.min_lon) / steps_lon - - coords = [] - for i in range(steps_lat + 1): - for j in range(steps_lon + 1): - lat = req.min_lat + (i * lat_step) - lon = req.min_lon + (j * lon_step) - coords.append((lat, lon)) - - # Batch fetch elevations - elevs = tile_manager.get_elevations_batch(coords) - - candidates = [] - for i, (lat, lon) in enumerate(coords): - # Basic Candidate - cand = { - "lat": lat, - "lon": lon, - "elevation": elevs[i] - } - # Score Components - metrics = optimization_service.score_candidate( - cand, - req.weights, - req.existing_nodes, - tx_height=req.tx_height, - rx_height=req.rx_height, - freq_mhz=req.frequency_mhz, - k_factor=req.k_factor, - clutter_height=req.clutter_height - ) - cand.update(metrics) # Adds prominence, fresnel - candidates.append(cand) - - # Normalize and Calculate Final Score - if not candidates: - return {"status": "success", "locations": []} - - max_elev = max([c['elevation'] for c in candidates]) or 1.0 - max_prom = max([c['prominence'] for c in candidates]) or 1.0 - # Fresnel is already 0-1 - - w_elev = req.weights.get("elevation", 0.3) - w_prom = req.weights.get("prominence", 0.4) - w_fres = req.weights.get("fresnel", 0.3) - - for c in candidates: - norm_elev = c['elevation'] / max_elev if max_elev > 0 else 0 - norm_prom = c['prominence'] / max_prom if max_prom > 0 else 0 - - c['score'] = (norm_elev * w_elev) + (norm_prom * w_prom) + (c['fresnel'] * w_fres) - # Scale to 0-100 for display - c['score'] = round(c['score'] * 100, 1) - - # Sort by Score desc - candidates.sort(key=lambda x: x["score"], reverse=True) - - # Take top 5 for "Ghost Nodes" - top_results = candidates[:5] - - response = { - "status": "success", - "locations": top_results, - "metadata": { - "max_elevation": max_elev, - "max_prominence": max_prom - } - } - - if req.return_heatmap: - # Send simplified data for heatmap (lat, lon, score) - # To save bandwidth, maybe round lat/lon? - heatmap_data = [ - {"lat": round(c['lat'], 5), "lon": round(c['lon'], 5), "score": c['score']} - for c in candidates - ] - response["heatmap"] = heatmap_data - - return response - except Exception as e: - print(f"Optimize Error: {e}") - from fastapi.responses import JSONResponse - return JSONResponse( - status_code=500, - content={"status": "error", "message": f"Server Error: {str(e)}"} - ) - -class ExportRequest(BaseModel): - locations: list - format: str = "csv" # csv, kml - -@app.post("/export-results") -def export_results_endpoint(req: ExportRequest): - """ - Generate export file for site candidates. - """ - from api.export import generate_csv, generate_kml - - if req.format == "kml": - content = generate_kml(req.locations) - media_type = "application/vnd.google-earth.kml+xml" - filename = "rf_scan_results.kml" - else: - content = generate_csv(req.locations) - media_type = "text/csv" - filename = "rf_scan_results.csv" - - return Response( - content=content, - media_type=media_type, - headers={"Content-Disposition": f"attachment; filename={filename}"} - ) diff --git a/rf-engine/tests/test_optimization.py b/rf-engine/tests/test_optimization.py index 8501ff9..81090b0 100644 --- a/rf-engine/tests/test_optimization.py +++ b/rf-engine/tests/test_optimization.py @@ -78,6 +78,6 @@ def test_score_candidate_with_rx(self, mock_tile_manager): ) service.check_fresnel_clearance.assert_called_with( - 0, 0, 20.0, rx_list, 433.0 + 0, 0, 20.0, rx_list, 433.0, k_factor=1.333, clutter_height=0.0 ) assert metrics['fresnel'] == 0.5 diff --git a/rf-engine/tile_manager.py b/rf-engine/tile_manager.py index 5beb534..1db01bd 100644 --- a/rf-engine/tile_manager.py +++ b/rf-engine/tile_manager.py @@ -1,32 +1,26 @@ -import requests -import msgpack import mercantile import numpy as np import logging -import os -import scipy.ndimage import threading from concurrent.futures import ThreadPoolExecutor, TimeoutError -from requests.adapters import HTTPAdapter from collections import OrderedDict +from cache_layer import CacheLayer +from elevation_client import ElevationClient +from grid_processor import GridProcessor + logger = logging.getLogger(__name__) class TileManager: def __init__(self, redis_client): - self.redis = redis_client self.zoom = 12 # Standard zoom level for 30m resolution approx - self.ttl = 30 * 24 * 60 * 60 # 30 Days - # Connection pooling for high concurrency - self.session = requests.Session() - adapter = HTTPAdapter(pool_connections=50, pool_maxsize=50) - self.session.mount('http://', adapter) - self.session.mount('https://', adapter) + # New Components + self.cache_layer = CacheLayer(redis_client) + self.elevation_client = ElevationClient() # manages its own executor - # Separate executors to prevent deadlocks - self.tile_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix='tile_') - self.batch_executor = ThreadPoolExecutor(max_workers=30, thread_name_prefix='batch_') + # Executor for parallel tile retrieval (Cache/API coordination) + self.tile_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix='tile_mgr_') # Request coalescing to prevent thundering herd (LRU-capped to prevent unbounded growth) self.tile_locks = OrderedDict() @@ -47,7 +41,7 @@ def get_tile_data(self, lat=None, lon=None, tile_x=None, tile_y=None, zoom=None) tile_key = f"tile:{zoom}:{tile_x}:{tile_y}" # 1. Fast check cache - data = self._get_tile_from_cache(tile_key) + data = self.cache_layer.get_tile(tile_key) if data: return data @@ -63,21 +57,21 @@ def get_tile_data(self, lat=None, lon=None, tile_x=None, tile_y=None, zoom=None) with lock: # Double check cache inside lock - data = self._get_tile_from_cache(tile_key) + data = self.cache_layer.get_tile(tile_key) if data: return data logger.info(f"Cache miss for tile {tile_key}. Fetching from API.") - data = self._fetch_tile_from_api(tile_x, tile_y, zoom) + data = self.elevation_client.fetch_tile(tile_x, tile_y, zoom) if data: - self._cache_tile(tile_key, data) + self.cache_layer.cache_tile(tile_key, data) return data def shutdown(self): """Shutdown thread pools gracefully.""" self.tile_executor.shutdown(wait=False) - self.batch_executor.shutdown(wait=False) + self.elevation_client.shutdown() def __del__(self): try: @@ -90,15 +84,15 @@ def get_elevation(self, lat, lon): Get elevation for a specific coordinate. Transparently handles caching and fetching tiles. """ - logger.info(f"Getting elevation for lat={lat}, lon={lon}") + # logger.info(f"Getting elevation for lat={lat}, lon={lon}") tile = mercantile.tile(lon, lat, self.zoom) - logger.info(f"Tile coordinates: x={tile.x}, y={tile.y}, z={self.zoom}") + # logger.info(f"Tile coordinates: x={tile.x}, y={tile.y}, z={self.zoom}") data = self.get_tile_data(lat=lat, lon=lon) if data: - logger.info(f"Got tile data, first 5 elevation values: {data.get('elevation', [])[:5] if 'elevation' in data else 'NO ELEVATION KEY'}") - result = self._extract_elevation_from_tile(data, lat, lon, tile) - logger.info(f"Extracted elevation: {result}") + # logger.info(f"Got tile data") + result = GridProcessor.extract_elevation_from_tile(data, lat, lon, tile) + # logger.info(f"Extracted elevation: {result}") return result logger.warning("No tile data returned!") return 0.0 @@ -113,120 +107,12 @@ def get_elevation_profile(self, lat1, lon1, lat2, lon2, samples=50): return self.get_elevations_batch(coords) - - - def _fetch_tile_from_api(self, x, y, z): - """ - Fetch elevation data from OpenTopoData API. - Supports custom OpenTopoData instances via ELEVATION_API_URL env variable. - Falls back to public OpenTopoData (api.opentopodata.org) if not set. - - OpenTopoData supports batch requests (up to 100 points per call), - which reduces API calls significantly compared to individual point queries. - """ - bounds = mercantile.bounds(x, y, z) - lat_min, lat_max = bounds.south, bounds.north - lon_min, lon_max = bounds.west, bounds.east - - base_url = os.environ.get('ELEVATION_API_URL', 'http://opentopodata:5000') - dataset = os.environ.get('ELEVATION_DATASET', 'srtm30m') - - # Create 16x16 grid of coordinates - lats = np.linspace(lat_min, lat_max, 16) - lons = np.linspace(lon_min, lon_max, 16) - - lat_grid, lon_grid = np.meshgrid(lats, lons) - lat_flat = lat_grid.flatten() - lon_flat = lon_grid.flatten() - - # OpenTopoData supports up to 100 locations per request - # We have 256 points (16x16), so split into 3 batches: 100, 100, 56 - batch_size = 100 - - batches = [] - for i in range(0, len(lat_flat), batch_size): - batch_lats = lat_flat[i:i + batch_size] - batch_lons = lon_flat[i:i + batch_size] - locations = "|".join([f"{lat},{lon}" for lat, lon in zip(batch_lats, batch_lons)]) - batches.append(locations) - - url = f"{base_url}/v1/{dataset}" - - def fetch_batch(locations, batch_num): - try: - # No artificial delay needed for local deployments - response = self.session.get( - url, - params={'locations': locations}, - timeout=10 - ) - - if response.status_code == 200: - data = response.json() - if data.get('status') == 'OK' and 'results' in data: - return [result.get('elevation', 0.0) for result in data['results']] - else: - error_msg = data.get('error', 'Unknown error') - logger.error(f"OpenTopoData batch {batch_num} error: {error_msg}") - return None - elif response.status_code == 404: - logger.error(f"Dataset '{dataset}' not found. Check ELEVATION_DATASET env var and data files.") - return None - else: - logger.warning(f"OpenTopoData batch {batch_num} failed with status {response.status_code}") - return None - - except requests.exceptions.Timeout: - logger.error(f"OpenTopoData request timed out for batch {batch_num}") - return None - except requests.exceptions.ConnectionError: - logger.error(f"Cannot connect to OpenTopoData at {base_url}. Is the container running?") - return None - except Exception as e: - logger.error(f"Exception fetching OpenTopoData batch {batch_num}: {e}") - return None - - # Execute batches in parallel - futures = [self.batch_executor.submit(fetch_batch, locs, i) for i, locs in enumerate(batches)] - - all_elevations = [] - for future in futures: - try: - batch_result = future.result(timeout=30) - except (TimeoutError, Exception) as e: - logger.error(f"Tile fetch timed out or failed: {e}") - return None - if batch_result is None: - return None - all_elevations.extend(batch_result) - - if len(all_elevations) == 256: - logger.info(f"Successfully fetched elevation data from OpenTopoData ({dataset}): min={min(all_elevations):.1f}m, max={max(all_elevations):.1f}m") - return {"elevation": all_elevations} - else: - logger.error(f"Expected 256 elevation points, got {len(all_elevations)}") - return None - def get_interpolated_grid(self, x, y, z, size=256): """ Returns a (size, size) numpy array of elevation data for the tile. - Upscales the low-res 16x16 fetched data. """ data = self.get_tile_data(tile_x=x, tile_y=y, zoom=z) - if not data or 'elevation' not in data: - return np.zeros((size, size)) - - raw_elev = np.array(data['elevation']) - if raw_elev.size != 16*16: - return np.zeros((size, size)) - - grid_16 = raw_elev.reshape((16, 16)).T - grid_16 = np.flipud(grid_16) - - zoom_factor = size / 16.0 - high_res_grid = scipy.ndimage.zoom(grid_16, zoom_factor, order=1) - - return high_res_grid + return GridProcessor.get_interpolated_grid(data, size) def get_elevations_batch(self, coords): """ @@ -269,66 +155,9 @@ def fetch_single_tile(tx, ty, tz): if data: # Need mercantile Tile object for extraction logic - elev = self._extract_elevation_from_tile(data, lat, lon, tile) + elev = GridProcessor.extract_elevation_from_tile(data, lat, lon, tile) results.append(elev) else: results.append(0.0) return results - - def _cache_tile(self, key, data): - packed = msgpack.packb(data) - self.redis.setex(key, self.ttl, packed) - - def _get_tile_from_cache(self, key): - packed = self.redis.get(key) - if packed: - return msgpack.unpackb(packed) - return None - - def _extract_elevation_from_tile(self, data, lat, lon, tile): - """ - Performs bilinear interpolation on the 16x16 grid to find elevation at lat, lon. - """ - if not data or 'elevation' not in data: - return 0.0 - - raw_elev = np.array(data['elevation']) - if raw_elev.size != 256: - return 0.0 - - grid = raw_elev.reshape((16, 16)) - - bounds = mercantile.bounds(tile) - lat_min, lat_max = bounds.south, bounds.north - lon_min, lon_max = bounds.west, bounds.east - - if lat_max == lat_min or lon_max == lon_min: - return 0.0 - - u = (lat - lat_min) / (lat_max - lat_min) * 15.0 - v = (lon - lon_min) / (lon_max - lon_min) * 15.0 - - u = np.clip(u, 0, 15) - v = np.clip(v, 0, 15) - - i = int(np.floor(u)) - j = int(np.floor(v)) - - u_ratio = u - i - v_ratio = v - j - - i_next = min(i + 1, 15) - j_next = min(j + 1, 15) - - p00 = grid[j, i] - p10 = grid[j, i_next] - p01 = grid[j_next, i] - p11 = grid[j_next, i_next] - - val_j = (p00 * (1 - u_ratio)) + (p10 * u_ratio) - val_jnext = (p01 * (1 - u_ratio)) + (p11 * u_ratio) - - final_elev = (val_j * (1 - v_ratio)) + (val_jnext * v_ratio) - - return float(final_elev)