Files
rfcp/docs/devlog/back/RFCP-Iteration-1.6-Enhanced-Environment.md

863 lines
25 KiB
Markdown
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.
# RFCP Iteration 1.6: Enhanced Environment + Bugfixes
**Date:** January 31, 2025
**Type:** Backend Enhancement + Bugfixes
**Estimated:** 8-12 hours
**Location:** `/opt/rfcp/backend/`
---
## 🎯 Goal
Fix critical bugs from 1.5, add spatial indexing for performance, implement water/ground reflections and vegetation loss.
---
## 🐛 Bugfixes (Priority)
### Bug 1: OSM `building:levels` Parsing Error
**Error:**
```
ValueError: invalid literal for int() with base 10: '1а'
```
**Cause:** OSM data contains non-numeric levels like `"1а"`, `"2-3"`, `"5+"`, etc.
**Location:** `app/services/buildings_service.py` line ~160
**Fix:**
```python
# Add to BuildingsService class
def _safe_int(self, 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):
import re
match = re.search(r'\d+', str(value))
if match:
return int(match.group())
return None
# Replace line ~160
# OLD:
levels=int(tags.get("building:levels", 0)) or None,
# NEW:
levels=self._safe_int(tags.get("building:levels")),
```
---
### Bug 2: Height Parsing Error
**Similar issue with `height` tag:**
```python
# OSM can have: "10 m", "10m", "10.5", "~12"
def _safe_float(self, value) -> Optional[float]:
"""Safely parse float from OSM tag"""
if not value:
return None
try:
# Remove common suffixes
cleaned = str(value).lower().replace('m', '').replace('~', '').strip()
return float(cleaned)
except (ValueError, TypeError):
import re
match = re.search(r'[\d.]+', str(value))
if match:
return float(match.group())
return None
```
---
### Bug 3: Request Timeout / Queue Blocking
**Issue:** Long calculations block other requests
**Fix:** Add timeout to coverage calculation
```python
# app/api/routes/coverage.py
import asyncio
@router.post("/calculate")
async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
try:
# Add timeout (5 minutes max)
result = await asyncio.wait_for(
coverage_service.calculate_coverage(...),
timeout=300.0
)
return result
except asyncio.TimeoutError:
raise HTTPException(408, "Calculation timeout - try smaller radius or lower resolution")
```
---
## ✅ Enhancement Tasks
### Task 1: Spatial Indexing with R-tree (3-4 hours)
**Install:**
```bash
pip install Rtree
```
**app/services/spatial_index.py:**
```python
from rtree import index
from typing import List, Tuple, Optional
from app.services.buildings_service import Building
class SpatialIndex:
"""R-tree spatial index for fast building lookups"""
def __init__(self):
self._index: Optional[index.Index] = None
self._buildings: dict[int, Building] = {}
self._bounds: Optional[Tuple[float, float, float, float]] = None
def build(self, buildings: List[Building]):
"""Build spatial index from buildings list"""
self._index = index.Index()
self._buildings = {}
for i, building in enumerate(buildings):
# Get bounding box of building
lons = [p[0] for p in building.geometry]
lats = [p[1] for p in building.geometry]
bbox = (min(lons), min(lats), max(lons), max(lats))
self._index.insert(i, bbox)
self._buildings[i] = building
if buildings:
all_lons = [p[0] for b in buildings for p in b.geometry]
all_lats = [p[1] for b in buildings for p in b.geometry]
self._bounds = (min(all_lons), min(all_lats), max(all_lons), max(all_lats))
def query_point(self, lat: float, lon: float, buffer: float = 0.001) -> List[Building]:
"""Find buildings near a point"""
if not self._index:
return []
bbox = (lon - buffer, lat - buffer, lon + buffer, lat + buffer)
indices = list(self._index.intersection(bbox))
return [self._buildings[i] for i in indices]
def query_line(
self,
lat1: float, lon1: float,
lat2: float, lon2: float,
buffer: float = 0.001
) -> List[Building]:
"""Find buildings along a line (for LoS checks)"""
if not self._index:
return []
# Bounding box of line + buffer
min_lon = min(lon1, lon2) - buffer
max_lon = max(lon1, lon2) + buffer
min_lat = min(lat1, lat2) - buffer
max_lat = max(lat1, lat2) + buffer
bbox = (min_lon, min_lat, max_lon, max_lat)
indices = list(self._index.intersection(bbox))
return [self._buildings[i] for i in indices]
def query_bbox(
self,
min_lat: float, min_lon: float,
max_lat: float, max_lon: float
) -> List[Building]:
"""Find all buildings in bounding box"""
if not self._index:
return []
bbox = (min_lon, min_lat, max_lon, max_lat)
indices = list(self._index.intersection(bbox))
return [self._buildings[i] for i in indices]
# Global instance with caching
_spatial_indices: dict[str, SpatialIndex] = {}
def get_spatial_index(cache_key: str, buildings: List[Building]) -> SpatialIndex:
"""Get or create spatial index for buildings"""
if cache_key not in _spatial_indices:
idx = SpatialIndex()
idx.build(buildings)
_spatial_indices[cache_key] = idx
# Limit cache size
if len(_spatial_indices) > 20:
oldest = next(iter(_spatial_indices))
del _spatial_indices[oldest]
return _spatial_indices[cache_key]
```
**Update coverage_service.py:**
```python
from app.services.spatial_index import get_spatial_index
# In calculate_coverage():
if settings.use_buildings and buildings:
# Build spatial index once
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
spatial_idx = get_spatial_index(cache_key, buildings)
# In _calculate_point():
# OLD: for building in buildings:
# NEW:
nearby_buildings = spatial_idx.query_point(lat, lon)
for building in nearby_buildings:
```
---
### Task 2: Water Reflection (2-3 hours)
**app/services/water_service.py:**
```python
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, # generic
}
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():
with open(cache_file) as f:
data = json.load(f)
bodies = [WaterBody(**w) for w in data]
self._cache[cache_key] = bodies
return bodies
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
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
def _point_in_polygon(self, lat: float, lon: float, polygon: List[Tuple[float, float]]) -> bool:
"""Ray casting algorithm"""
n = len(polygon)
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i]
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()
```
---
### Task 3: Vegetation Loss (2-3 hours)
**app/services/vegetation_service.py:**
```python
import httpx
from typing import List, Tuple, Optional
from pydantic import BaseModel
import json
from pathlib import Path
class VegetationArea(BaseModel):
"""Vegetation area from OSM"""
id: int
geometry: List[Tuple[float, float]]
vegetation_type: str # forest, wood, scrub, orchard
density: str # dense, sparse, mixed
class VegetationService:
"""OSM vegetation for signal attenuation"""
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
# Attenuation dB per 100 meters
ATTENUATION_DB_PER_100M = {
"forest": 8.0, # Dense forest
"wood": 6.0, # Woods
"tree_row": 2.0, # Single row of trees
"scrub": 3.0, # Bushes
"orchard": 2.0, # Fruit trees (spaced)
"vineyard": 1.0, # Low vegetation
"meadow": 0.5, # Grass only
}
# Seasonal factor (summer = full foliage)
SEASONAL_FACTOR = {
"summer": 1.0,
"winter": 0.3, # Deciduous trees lose leaves
"spring": 0.6,
"autumn": 0.7,
}
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/vegetation"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True, parents=True)
self._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"""
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():
with open(cache_file) as f:
data = json.load(f)
areas = [VegetationArea(**v) for v in data]
self._cache[cache_key] = areas
return areas
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)
# Cache
with open(cache_file, 'w') as f:
json.dump([v.model_dump() for v in areas], f)
self._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
# Determine density
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
Returns loss in dB
"""
from app.services.terrain_service import TerrainService
total_loss = 0.0
path_length = TerrainService.haversine_distance(lat1, lon1, lat2, lon2)
if path_length < 1:
return 0.0
# Sample points along path
num_samples = max(10, int(path_length / 50)) # Every 50m
vegetation_distance = 0.0
current_veg_type = None
for i in range(num_samples):
t = i / num_samples
lat = lat1 + t * (lat2 - lat1)
lon = lon1 + t * (lon2 - lon1)
# Check if point is in vegetation
veg = self._point_in_vegetation(lat, lon, vegetation_areas)
if veg:
segment_length = path_length / num_samples
vegetation_distance += segment_length
current_veg_type = veg.vegetation_type
if vegetation_distance > 0 and current_veg_type:
# Calculate loss
attenuation = self.ATTENUATION_DB_PER_100M.get(current_veg_type, 4.0)
seasonal = self.SEASONAL_FACTOR.get(season, 1.0)
total_loss = (vegetation_distance / 100) * attenuation * seasonal
return min(total_loss, 40.0) # Cap at 40 dB
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
def _point_in_polygon(self, lat: float, lon: float, polygon: List[Tuple[float, float]]) -> bool:
"""Ray casting algorithm"""
n = len(polygon)
inside = False
j = n - 1
for i in range(n):
xi, yi = polygon[i]
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()
```
---
### Task 4: Ground Reflection (1-2 hours)
**Update app/services/reflection_service.py:**
```python
# Add to ReflectionService class
# Ground types and reflection coefficients
GROUND_REFLECTION = {
"urban": 0.3, # Asphalt, concrete
"suburban": 0.4, # Mixed
"rural": 0.5, # Grass, soil
"water": 0.8, # Lakes, rivers
"desert": 0.6, # Sand
}
def calculate_ground_reflection(
self,
tx_lat: float, tx_lon: float, tx_height: float,
rx_lat: float, rx_lon: float, rx_height: float,
frequency_mhz: float,
ground_type: str = "rural",
water_body: Optional[WaterBody] = None
) -> ReflectionPath:
"""Calculate ground/water reflection path"""
from app.services.terrain_service import TerrainService
# Direct distance
direct_dist = TerrainService.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
# Reflection point (simplified - midpoint)
mid_lat = (tx_lat + rx_lat) / 2
mid_lon = (tx_lon + rx_lon) / 2
# Get ground elevation at reflection point
ground_elev = await self.terrain.get_elevation(mid_lat, mid_lon)
# Path lengths (TX -> ground -> RX)
d1 = direct_dist / 2
h1 = tx_height
h2 = rx_height
# Actual path length
path1 = np.sqrt(d1**2 + h1**2)
path2 = np.sqrt(d1**2 + h2**2)
total_path = path1 + path2
# Reflection coefficient
if water_body:
coeff = self.GROUND_REFLECTION["water"]
else:
coeff = self.GROUND_REFLECTION.get(ground_type, 0.4)
# Path loss
path_loss = self._free_space_loss(total_path, frequency_mhz)
reflection_loss = -10 * np.log10(coeff)
total_loss = path_loss + reflection_loss
return ReflectionPath(
points=[(tx_lat, tx_lon), (mid_lat, mid_lon), (rx_lat, rx_lon)],
total_distance=total_path,
total_loss=total_loss,
reflection_count=1,
materials=["ground" if not water_body else "water"]
)
```
---
### Task 5: Integrate into Coverage Service (2 hours)
**Update app/services/coverage_service.py:**
```python
# Add imports
from app.services.spatial_index import get_spatial_index
from app.services.water_service import water_service
from app.services.vegetation_service import vegetation_service
# Update CoverageSettings
class CoverageSettings(BaseModel):
# ... existing fields ...
use_water_reflection: bool = False
use_vegetation: bool = False
season: str = "summer" # For vegetation loss
# Update calculate_coverage()
async def calculate_coverage(self, site: SiteParams, settings: CoverageSettings):
# ... existing setup ...
# Fetch additional data if enabled
water_bodies = []
vegetation_areas = []
if settings.use_water_reflection:
water_bodies = await water_service.fetch_water_bodies(
min_lat, min_lon, max_lat, max_lon
)
if settings.use_vegetation:
vegetation_areas = await vegetation_service.fetch_vegetation(
min_lat, min_lon, max_lat, max_lon
)
# Build spatial index for buildings
spatial_idx = None
if settings.use_buildings and buildings:
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
spatial_idx = get_spatial_index(cache_key, buildings)
# Calculate points...
# Update _calculate_point()
async def _calculate_point(self, ...):
# ... existing code ...
# Vegetation loss
vegetation_loss = 0.0
if settings.use_vegetation and vegetation_areas:
vegetation_loss = vegetation_service.calculate_vegetation_loss(
site.lat, site.lon, lat, lon,
vegetation_areas, settings.season
)
# Water reflection boost
water_reflection_gain = 0.0
if settings.use_water_reflection and water_bodies:
water = water_service.point_over_water(lat, lon, water_bodies)
if water:
# Better reflection over water
water_reflection_gain = 3.0 # ~3dB boost
# Final RSRP
rsrp = (site.power + site.gain - path_loss - antenna_loss
- terrain_loss - building_loss - vegetation_loss
+ reflection_gain + water_reflection_gain)
```
---
### Task 6: Update API & Frontend Settings (1 hour)
**Update presets in coverage.py:**
```python
PRESETS = {
"fast": {
"use_terrain": True,
"use_buildings": False,
"use_materials": False,
"use_dominant_path": False,
"use_street_canyon": False,
"use_reflections": False,
"use_water_reflection": False,
"use_vegetation": False,
},
# ... standard, detailed ...
"full": {
"use_terrain": True,
"use_buildings": True,
"use_materials": True,
"use_dominant_path": True,
"use_street_canyon": True,
"use_reflections": True,
"use_water_reflection": True,
"use_vegetation": True,
},
}
```
---
## 📁 Files Summary
**New Files:**
```
app/services/
├── spatial_index.py # R-tree for fast building lookup
├── water_service.py # OSM water bodies
└── vegetation_service.py # OSM forest/vegetation
data/
├── water/ # Water bodies cache
└── vegetation/ # Vegetation cache
```
**Modified Files:**
```
app/services/
├── buildings_service.py # Fix parsing bugs
├── coverage_service.py # Integrate new services
└── reflection_service.py # Add ground reflection
app/api/routes/
└── coverage.py # Timeout, new settings
requirements.txt # Add Rtree
```
---
## 🧪 Testing
```bash
# Install new dependency
cd /opt/rfcp/backend
source venv/bin/activate
pip install Rtree
# Restart
sudo systemctl restart rfcp-backend
# Test parsing fix
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
-H "Content-Type: application/json" \
-d '{"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],"settings":{"radius":1000,"resolution":100,"preset":"standard"}}'
# Test full preset with new features
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
-H "Content-Type: application/json" \
-d '{"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],"settings":{"radius":500,"resolution":50,"preset":"full"}}'
# Run test scripts
./rfcp-integration-test.sh
./rfcp-propagation-test.sh
```
---
## ✅ Success Criteria
- [ ] `building:levels = "1а"` no longer crashes
- [ ] R-tree spatial index speeds up building queries
- [ ] Water bodies fetched and cached
- [ ] Vegetation areas fetched and cached
- [ ] Coverage includes `vegetation_loss` field
- [ ] Full preset uses all new features
- [ ] No timeout on reasonable requests (5km, 100m resolution)
- [ ] All tests pass (21/21 integration, 8/8 propagation)
---
## 📝 Notes
- R-tree requires `libspatialindex` system library
- Install on Ubuntu: `apt install libspatialindex-dev`
- Vegetation loss is seasonal — default to summer
- Water reflection most noticeable near large lakes/rivers
---
## 🔜 Next: 1.6.1 or 2.1
**1.6.1 — Extra Factors (optional):**
- Weather/rain attenuation
- Indoor penetration layer
- Time-of-day atmospheric effects
**2.1 — Desktop Installer:**
- Electron packaging
- Offline mode
- GPU acceleration
---
**Ready for Claude Code** 🚀