import httpx import asyncio from typing import List, Optional from pydantic import BaseModel from functools import lru_cache import hashlib import json from pathlib import Path class Building(BaseModel): """Single building footprint""" id: int geometry: List[List[float]] # [[lon, lat], ...] height: float # meters levels: Optional[int] = None building_type: Optional[str] = None class BuildingsService: """ OpenStreetMap buildings via Overpass API """ OVERPASS_URL = "https://overpass-api.de/api/interpreter" DEFAULT_LEVEL_HEIGHT = 3.0 # meters per floor DEFAULT_BUILDING_HEIGHT = 9.0 # 3 floors if unknown def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/buildings"): self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(exist_ok=True, parents=True) self._memory_cache: dict[str, List[Building]] = {} self._max_cache_size = 50 # bbox regions def _bbox_key(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> str: """Generate cache key for bbox""" # Round to 0.01 degree (~1km) grid for cache efficiency key = f"{min_lat:.2f},{min_lon:.2f},{max_lat:.2f},{max_lon:.2f}" return hashlib.md5(key.encode()).hexdigest()[:12] async def fetch_buildings( self, min_lat: float, min_lon: float, max_lat: float, max_lon: float, use_cache: bool = True ) -> List[Building]: """ Fetch buildings in bounding box from OSM Args: min_lat, min_lon, max_lat, max_lon: Bounding box use_cache: Whether to use cached results Returns: List of Building objects with height estimates """ cache_key = self._bbox_key(min_lat, min_lon, max_lat, max_lon) # Check memory cache if use_cache and cache_key in self._memory_cache: return self._memory_cache[cache_key] # Check disk cache cache_file = self.cache_dir / f"{cache_key}.json" if use_cache and cache_file.exists(): try: with open(cache_file, 'r') as f: data = json.load(f) buildings = [Building(**b) for b in data] self._memory_cache[cache_key] = buildings return buildings except Exception: pass # Fetch fresh if cache corrupted # Fetch from Overpass API query = f""" [out:json][timeout:30]; ( way["building"]({min_lat},{min_lon},{max_lat},{max_lon}); relation["building"]({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"Overpass API error: {e}") return [] # Parse response buildings = self._parse_overpass_response(data) # Cache results if buildings: # Disk cache with open(cache_file, 'w') as f: json.dump([b.model_dump() for b in buildings], f) # Memory cache (with size limit) if len(self._memory_cache) >= self._max_cache_size: oldest = next(iter(self._memory_cache)) del self._memory_cache[oldest] self._memory_cache[cache_key] = buildings return buildings def _parse_overpass_response(self, data: dict) -> List[Building]: """Parse Overpass JSON response into Building objects""" buildings = [] # Build node lookup nodes = {} for element in data.get("elements", []): if element["type"] == "node": nodes[element["id"]] = (element["lon"], element["lat"]) # Process ways (building footprints) for element in data.get("elements", []): if element["type"] != "way": continue tags = element.get("tags", {}) if "building" not in tags: continue # Get geometry geometry = [] for node_id in element.get("nodes", []): if node_id in nodes: geometry.append(list(nodes[node_id])) if len(geometry) < 3: continue # Invalid polygon # Estimate height height = self._estimate_height(tags) buildings.append(Building( id=element["id"], geometry=geometry, height=height, levels=int(tags.get("building:levels", 0)) or None, building_type=tags.get("building") )) return buildings def _estimate_height(self, tags: dict) -> float: """Estimate building height from OSM tags""" # Explicit height tag if "height" in tags: try: h = tags["height"] # Handle "10 m" or "10m" format if isinstance(h, str): h = h.replace("m", "").replace(" ", "") return float(h) except (ValueError, TypeError): pass # Calculate from levels if "building:levels" in tags: try: levels = int(tags["building:levels"]) return levels * self.DEFAULT_LEVEL_HEIGHT except (ValueError, TypeError): pass # Default based on building type building_type = tags.get("building", "yes") type_heights = { "house": 6.0, "residential": 12.0, "apartments": 18.0, "commercial": 12.0, "industrial": 8.0, "warehouse": 6.0, "garage": 3.0, "shed": 2.5, "roof": 3.0, "church": 15.0, "cathedral": 30.0, "hospital": 15.0, "school": 12.0, "university": 15.0, "office": 20.0, "retail": 6.0, } return type_heights.get(building_type, self.DEFAULT_BUILDING_HEIGHT) def point_in_building(self, lat: float, lon: float, building: Building) -> bool: """Check if point is inside building footprint (ray casting)""" x, y = lon, lat polygon = building.geometry n = len(polygon) inside = False j = n - 1 for i in range(n): xi, yi = polygon[i] xj, yj = polygon[j] if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi): inside = not inside j = i return inside def line_intersects_building( self, lat1: float, lon1: float, height1: float, lat2: float, lon2: float, height2: float, building: Building ) -> Optional[float]: """ Check if line segment intersects building Returns: Distance along path where intersection occurs, or None """ # Simplified 2D check + height comparison # For accurate 3D intersection, would need proper ray-polygon intersection from app.services.terrain_service import TerrainService # Sample points along line num_samples = 20 for i in range(num_samples): t = i / num_samples lat = lat1 + t * (lat2 - lat1) lon = lon1 + t * (lon2 - lon1) height = height1 + t * (height2 - height1) if self.point_in_building(lat, lon, building): # Check if signal height is below building if height < building.height: # Calculate distance dist = t * TerrainService.haversine_distance(lat1, lon1, lat2, lon2) return dist return None # Singleton instance buildings_service = BuildingsService()