Files
rfcp/backend/app/services/buildings_service.py

288 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
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
material: Optional[str] = None # Detected material type
tags: dict = {} # Store all OSM tags for material detection
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
@staticmethod
def _safe_int(value) -> Optional[int]:
"""Safely parse int from OSM tag (handles '1а', '2-3', '5+', etc.)"""
if not value:
return None
try:
return int(value)
except (ValueError, TypeError):
match = re.search(r'\d+', str(value))
if match:
return int(match.group())
return None
@staticmethod
def _safe_float(value) -> Optional[float]:
"""Safely parse float from OSM tag (handles '10 m', '~12', '10m')"""
if not value:
return None
try:
cleaned = str(value).lower().replace('m', '').replace('~', '').strip()
return float(cleaned)
except (ValueError, TypeError):
match = re.search(r'[\d.]+', str(value))
if match:
return float(match.group())
return None
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)
# Detect material from tags
material_str = None
if "building:material" in tags:
material_str = tags["building:material"]
elif "building:facade:material" in tags:
material_str = tags["building:facade:material"]
buildings.append(Building(
id=element["id"],
geometry=geometry,
height=height,
levels=self._safe_int(tags.get("building:levels")),
building_type=tags.get("building"),
material=material_str,
tags=tags
))
return buildings
def _estimate_height(self, tags: dict) -> float:
"""Estimate building height from OSM tags"""
# Explicit height tag
if "height" in tags:
h = self._safe_float(tags["height"])
if h is not None and h > 0:
return h
# Calculate from levels
if "building:levels" in tags:
levels = self._safe_int(tags["building:levels"])
if levels is not None and levels > 0:
return levels * self.DEFAULT_LEVEL_HEIGHT
# 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()