""" OSM water bodies service for RF reflection calculations. Water surfaces produce strong specular reflections that can boost or create multipath interference for RF signals. """ import httpx from typing import List, Tuple, Optional from pydantic import BaseModel import json from pathlib import Path class WaterBody(BaseModel): """Water body from OSM""" id: int geometry: List[Tuple[float, float]] # [(lon, lat), ...] water_type: str # river, lake, pond, reservoir name: Optional[str] = None class WaterService: """OSM water bodies for reflection calculations""" OVERPASS_URL = "https://overpass-api.de/api/interpreter" # Reflection coefficients by water type REFLECTION_COEFF = { "lake": 0.8, "reservoir": 0.8, "river": 0.7, "pond": 0.75, "water": 0.7, } def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/water"): self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(exist_ok=True, parents=True) self._cache: dict[str, List[WaterBody]] = {} async def fetch_water_bodies( self, min_lat: float, min_lon: float, max_lat: float, max_lon: float ) -> List[WaterBody]: """Fetch water bodies in bounding box""" cache_key = f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}" if cache_key in self._cache: return self._cache[cache_key] cache_file = self.cache_dir / f"{cache_key}.json" if cache_file.exists(): try: with open(cache_file) as f: data = json.load(f) bodies = [WaterBody(**w) for w in data] self._cache[cache_key] = bodies return bodies except Exception: pass query = f""" [out:json][timeout:30]; ( way["natural"="water"]({min_lat},{min_lon},{max_lat},{max_lon}); relation["natural"="water"]({min_lat},{min_lon},{max_lat},{max_lon}); way["waterway"]({min_lat},{min_lon},{max_lat},{max_lon}); ); out body; >; out skel qt; """ try: async with httpx.AsyncClient(timeout=60.0) as client: response = await client.post(self.OVERPASS_URL, data={"data": query}) response.raise_for_status() data = response.json() except Exception as e: print(f"Water fetch error: {e}") return [] bodies = self._parse_response(data) # Cache if bodies: with open(cache_file, 'w') as f: json.dump([w.model_dump() for w in bodies], f) self._cache[cache_key] = bodies return bodies def _parse_response(self, data: dict) -> List[WaterBody]: """Parse Overpass response""" nodes = {} for element in data.get("elements", []): if element["type"] == "node": nodes[element["id"]] = (element["lon"], element["lat"]) bodies = [] for element in data.get("elements", []): if element["type"] != "way": continue tags = element.get("tags", {}) # Determine water type water_type = tags.get("water", tags.get("waterway", tags.get("natural", "water"))) geometry = [] for node_id in element.get("nodes", []): if node_id in nodes: geometry.append(nodes[node_id]) if len(geometry) < 3: continue bodies.append(WaterBody( id=element["id"], geometry=geometry, water_type=water_type, name=tags.get("name") )) return bodies def get_reflection_coefficient(self, water_type: str) -> float: """Get reflection coefficient for water type""" return self.REFLECTION_COEFF.get(water_type, 0.7) def point_over_water( self, lat: float, lon: float, water_bodies: List[WaterBody] ) -> Optional[WaterBody]: """Check if point is over water""" for body in water_bodies: if self._point_in_polygon(lat, lon, body.geometry): return body return None @staticmethod def _point_in_polygon( lat: float, lon: float, polygon: List[Tuple[float, float]] ) -> bool: """Ray casting algorithm — polygon coords are (lon, lat)""" n = len(polygon) inside = False j = n - 1 for i in range(n): xi, yi = polygon[i] # lon, lat xj, yj = polygon[j] if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi): inside = not inside j = i return inside water_service = WaterService()