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

274 lines
8.5 KiB
Python

"""
OSM vegetation service for RF signal attenuation.
Forests and dense vegetation attenuate RF signals significantly.
Uses ITU-R P.833 approximations for foliage loss.
"""
import os
import httpx
import json
from typing import List, Tuple, Optional
from pydantic import BaseModel
from pathlib import Path
from datetime import datetime, timedelta
class VegetationArea(BaseModel):
"""Vegetation area from OSM"""
id: int
geometry: List[Tuple[float, float]] # [(lon, lat), ...]
vegetation_type: str # forest, wood, scrub, orchard
density: str # dense, sparse, mixed
class VegetationCache:
"""Local cache for vegetation data with expiry"""
CACHE_EXPIRY_DAYS = 30
def __init__(self):
self.data_path = Path(os.environ.get('RFCP_DATA_PATH', './data'))
self.cache_path = self.data_path / 'osm' / 'vegetation'
self.cache_path.mkdir(parents=True, exist_ok=True)
def _get_cache_key(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> str:
return f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}"
def _get_cache_file(self, cache_key: str) -> Path:
return self.cache_path / f"{cache_key}.json"
def get(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> Optional[list]:
cache_key = self._get_cache_key(min_lat, min_lon, max_lat, max_lon)
cache_file = self._get_cache_file(cache_key)
if not cache_file.exists():
return None
try:
data = json.loads(cache_file.read_text())
cached_at = datetime.fromisoformat(data.get('_cached_at', '2000-01-01'))
if datetime.now() - cached_at > timedelta(days=self.CACHE_EXPIRY_DAYS):
return None
return data.get('data')
except Exception as e:
print(f"[VegetationCache] Failed to read cache: {e}")
return None
def set(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float, data):
cache_key = self._get_cache_key(min_lat, min_lon, max_lat, max_lon)
cache_file = self._get_cache_file(cache_key)
try:
cache_data = {
'_cached_at': datetime.now().isoformat(),
'_bbox': [min_lat, min_lon, max_lat, max_lon],
'data': data
}
cache_file.write_text(json.dumps(cache_data))
except Exception as e:
print(f"[VegetationCache] Failed to write cache: {e}")
def clear(self):
for f in self.cache_path.glob("*.json"):
f.unlink()
def get_size_mb(self) -> float:
total = sum(f.stat().st_size for f in self.cache_path.glob("*.json"))
return total / (1024 * 1024)
class VegetationService:
"""OSM vegetation for signal attenuation"""
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Attenuation dB per 100 meters of vegetation
ATTENUATION_DB_PER_100M = {
"forest": 8.0,
"wood": 6.0,
"tree_row": 2.0,
"scrub": 3.0,
"orchard": 2.0,
"vineyard": 1.0,
"meadow": 0.5,
}
# Seasonal factor (summer = full foliage)
SEASONAL_FACTOR = {
"summer": 1.0,
"winter": 0.3,
"spring": 0.6,
"autumn": 0.7,
}
def __init__(self):
self.cache = VegetationCache()
self._memory_cache: dict[str, List[VegetationArea]] = {}
async def fetch_vegetation(
self,
min_lat: float, min_lon: float,
max_lat: float, max_lon: float
) -> List[VegetationArea]:
"""Fetch vegetation areas in bounding box, using cache if available"""
cache_key = f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}"
# Memory cache
if cache_key in self._memory_cache:
return self._memory_cache[cache_key]
# Disk cache with expiry
cached = self.cache.get(min_lat, min_lon, max_lat, max_lon)
if cached is not None:
print(f"[Vegetation] Cache hit for bbox")
areas = [VegetationArea(**v) for v in cached]
self._memory_cache[cache_key] = areas
return areas
# Fetch from Overpass
print(f"[Vegetation] Fetching from Overpass API...")
query = f"""
[out:json][timeout:30];
(
way["landuse"="forest"]({min_lat},{min_lon},{max_lat},{max_lon});
way["natural"="wood"]({min_lat},{min_lon},{max_lat},{max_lon});
way["landuse"="orchard"]({min_lat},{min_lon},{max_lat},{max_lon});
way["natural"="scrub"]({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"[Vegetation] Fetch error: {e}")
return []
areas = self._parse_response(data)
# Save to disk cache
if areas:
self.cache.set(min_lat, min_lon, max_lat, max_lon,
[v.model_dump() for v in areas])
self._memory_cache[cache_key] = areas
return areas
def _parse_response(self, data: dict) -> List[VegetationArea]:
"""Parse Overpass response"""
nodes = {}
for element in data.get("elements", []):
if element["type"] == "node":
nodes[element["id"]] = (element["lon"], element["lat"])
areas = []
for element in data.get("elements", []):
if element["type"] != "way":
continue
tags = element.get("tags", {})
veg_type = tags.get("landuse", tags.get("natural", "forest"))
geometry = []
for node_id in element.get("nodes", []):
if node_id in nodes:
geometry.append(nodes[node_id])
if len(geometry) < 3:
continue
leaf_type = tags.get("leaf_type", "mixed")
density = "dense" if leaf_type == "needleleaved" else "mixed"
areas.append(VegetationArea(
id=element["id"],
geometry=geometry,
vegetation_type=veg_type,
density=density
))
return areas
def calculate_vegetation_loss(
self,
lat1: float, lon1: float,
lat2: float, lon2: float,
vegetation_areas: List[VegetationArea],
season: str = "summer"
) -> float:
"""
Calculate signal loss through vegetation along path.
Samples points along the TX->RX path and accumulates
attenuation for each segment inside vegetation.
Returns loss in dB (capped at 40 dB).
"""
from app.services.terrain_service import TerrainService
path_length = TerrainService.haversine_distance(lat1, lon1, lat2, lon2)
if path_length < 1:
return 0.0
num_samples = max(10, int(path_length / 50))
segment_length = path_length / num_samples
total_loss = 0.0
for i in range(num_samples):
t = i / num_samples
lat = lat1 + t * (lat2 - lat1)
lon = lon1 + t * (lon2 - lon1)
veg = self._point_in_vegetation(lat, lon, vegetation_areas)
if veg:
attenuation = self.ATTENUATION_DB_PER_100M.get(veg.vegetation_type, 4.0)
seasonal = self.SEASONAL_FACTOR.get(season, 1.0)
total_loss += (segment_length / 100) * attenuation * seasonal
return min(total_loss, 40.0)
def _point_in_vegetation(
self,
lat: float, lon: float,
areas: List[VegetationArea]
) -> Optional[VegetationArea]:
"""Check if point is in vegetation area"""
for area in areas:
if self._point_in_polygon(lat, lon, area.geometry):
return area
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
vegetation_service = VegetationService()