@mytec: initial commit before dt

This commit is contained in:
2026-01-31 13:54:20 +02:00
parent 375a78f5b9
commit 04fe8fb814
18 changed files with 1087 additions and 730 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,366 @@
# RFCP Backend - Iteration 1.1: Foundation
**Date:** January 30, 2025
**Type:** Backend Development
**Estimated:** 2-3 hours
**Location:** `/opt/rfcp/backend/`
---
## 🎯 Goal
Set up basic FastAPI structure with MongoDB connection and CRUD endpoints for projects/sites.
---
## 📋 Pre-reading (IMPORTANT)
Before starting, read these documents in project knowledge:
1. `RFCP-Backend-Roadmap-Complete.md` — full backend architecture
2. `RFCP-ARCHITECTURE.md` — system overview
3. `SESSION-2025-01-30-Complete.md` — context
---
## 📊 Current State
```bash
# Service already running
systemctl status rfcp-backend.service # ✅ active
# Current structure
/opt/rfcp/backend/
├── app/
│ ├── __init__.py
│ └── main.py # Basic FastAPI app
├── venv/ # Python 3.12, FastAPI installed
├── Dockerfile
└── requirements.txt
# MongoDB available (Open5GS instance)
# Will use separate database: "rfcp"
```
---
## ✅ Tasks
### 1. Install Dependencies
```bash
cd /opt/rfcp/backend
source venv/bin/activate
pip install motor pydantic-settings pymongo
pip install numpy scipy requests
```
Update `requirements.txt` with new deps.
---
### 2. Create Directory Structure
```
app/
├── __init__.py
├── main.py
├── api/
│ ├── __init__.py
│ ├── deps.py
│ └── routes/
│ ├── __init__.py
│ ├── projects.py
│ └── health.py
├── core/
│ ├── __init__.py
│ ├── config.py
│ └── database.py
└── models/
├── __init__.py
├── project.py
└── site.py
```
---
### 3. Implement Core Components
**app/core/config.py:**
```python
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
MONGODB_URL: str = "mongodb://localhost:27017"
DATABASE_NAME: str = "rfcp"
class Config:
env_file = ".env"
settings = Settings()
```
**app/core/database.py:**
```python
from motor.motor_asyncio import AsyncIOMotorClient
from app.core.config import settings
class Database:
client: AsyncIOMotorClient = None
db = Database()
async def get_database():
return db.client[settings.DATABASE_NAME]
async def connect_to_mongo():
db.client = AsyncIOMotorClient(settings.MONGODB_URL)
async def close_mongo_connection():
if db.client:
db.client.close()
```
---
### 4. Implement Models
**app/models/site.py:**
```python
from pydantic import BaseModel
from typing import List, Optional
class Sector(BaseModel):
id: str
name: str # Alpha, Beta, Gamma...
power: float = 43 # dBm
gain: float = 8 # dBi
height: float = 30 # meters
frequency: float = 1800 # MHz
azimuth: float = 0 # degrees
beamwidth: float = 65 # degrees
tilt: float = 0 # degrees (electrical downtilt)
antenna_type: str = "directional"
class Site(BaseModel):
id: str
name: str
lat: float
lon: float
sectors: List[Sector] = []
```
**app/models/project.py:**
```python
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
from app.models.site import Site
class CoverageSettings(BaseModel):
radius: float = 10000 # meters
resolution: float = 200 # meters
min_signal: float = -105 # dBm
max_signal: float = -65 # dBm
class Project(BaseModel):
id: Optional[str] = None
name: str = "global"
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
sites: List[Site] = []
settings: CoverageSettings = Field(default_factory=CoverageSettings)
```
---
### 5. Implement Routes
**app/api/routes/health.py:**
```python
from fastapi import APIRouter, Depends
from app.core.database import get_database
router = APIRouter()
@router.get("/")
async def health_check():
return {"status": "ok", "service": "rfcp-backend"}
@router.get("/db")
async def db_check(db = Depends(get_database)):
try:
await db.command("ping")
return {"status": "ok", "database": "connected"}
except Exception as e:
return {"status": "error", "database": str(e)}
```
**app/api/routes/projects.py:**
```python
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from datetime import datetime
from app.core.database import get_database
from app.models.project import Project, CoverageSettings
from app.models.site import Site
router = APIRouter()
@router.get("/current")
async def get_current_project(db = Depends(get_database)):
"""Get the global project"""
project = await db.projects.find_one({"name": "global"})
if not project:
# Create default if doesn't exist
default = Project(name="global")
await db.projects.insert_one(default.model_dump())
return default
project.pop("_id", None)
return project
@router.put("/current")
async def update_current_project(project: Project, db = Depends(get_database)):
"""Update the global project"""
project.updated_at = datetime.utcnow()
data = project.model_dump()
await db.projects.update_one(
{"name": "global"},
{"$set": data},
upsert=True
)
return project
@router.get("/current/sites", response_model=List[Site])
async def get_sites(db = Depends(get_database)):
"""Get all sites"""
project = await db.projects.find_one({"name": "global"})
if not project:
return []
return project.get("sites", [])
@router.put("/current/sites")
async def update_sites(sites: List[Site], db = Depends(get_database)):
"""Update all sites"""
await db.projects.update_one(
{"name": "global"},
{
"$set": {
"sites": [s.model_dump() for s in sites],
"updated_at": datetime.utcnow()
}
},
upsert=True
)
return {"updated": len(sites)}
@router.get("/current/settings")
async def get_settings(db = Depends(get_database)):
"""Get coverage settings"""
project = await db.projects.find_one({"name": "global"})
if not project:
return CoverageSettings()
return project.get("settings", CoverageSettings().model_dump())
@router.put("/current/settings")
async def update_settings(settings: CoverageSettings, db = Depends(get_database)):
"""Update coverage settings"""
await db.projects.update_one(
{"name": "global"},
{
"$set": {
"settings": settings.model_dump(),
"updated_at": datetime.utcnow()
}
},
upsert=True
)
return settings
```
---
### 6. Update main.py
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.database import connect_to_mongo, close_mongo_connection
from app.api.routes import health, projects
@asynccontextmanager
async def lifespan(app: FastAPI):
await connect_to_mongo()
yield
await close_mongo_connection()
app = FastAPI(
title="RFCP Backend API",
description="RF Coverage Planning Backend",
version="1.1.0",
lifespan=lifespan
)
# CORS for frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["https://rfcp.eliah.one", "http://localhost:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Routes
app.include_router(health.router, prefix="/api/health", tags=["health"])
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
@app.get("/")
async def root():
return {"message": "RFCP Backend API", "version": "1.1.0"}
```
---
### 7. Restart Service & Test
```bash
# Restart
sudo systemctl restart rfcp-backend.service
# Check logs
journalctl -u rfcp-backend.service -f
# Test endpoints
curl http://10.10.10.1:8888/
curl http://10.10.10.1:8888/api/health/
curl http://10.10.10.1:8888/api/health/db
curl http://10.10.10.1:8888/api/projects/current
curl http://10.10.10.1:8888/docs # Swagger UI
```
---
## ✅ Success Criteria
- [ ] Service running without errors
- [ ] `/api/health/` returns `{"status": "ok"}`
- [ ] `/api/health/db` returns `{"database": "connected"}`
- [ ] `/api/projects/current` returns project (creates if empty)
- [ ] PUT `/api/projects/current/sites` saves sites to MongoDB
- [ ] Swagger docs accessible at `/docs`
---
## 📝 Notes
- MongoDB database: `rfcp` (separate from Open5GS)
- No auth for now (VPN = security layer)
- CORS configured for frontend
- Pydantic v2 syntax (`model_dump()` not `dict()`)
---
**Ready for Claude Code** 🚀

View File

@@ -0,0 +1,308 @@
# RFCP Frontend - Iteration 1.1.1: UX Safety & Undo/Redo
**Date:** January 30, 2025
**Type:** Frontend Enhancement
**Estimated:** 2-3 hours
**Location:** `/opt/rfcp/frontend/`
---
## 🎯 Goal
Add safety confirmations for destructive actions and implement full Undo/Redo system.
---
## 📋 Pre-reading
1. Review current state management in `src/store/` (Zustand)
2. Check existing toast implementation for delete actions
---
## 📊 Current State
- Toast exists for delete actions (basic)
- No unsaved changes detection
- No confirmation dialogs
- No undo/redo system
---
## ✅ Tasks
### 1. Unsaved Changes Detection
**Add dirty state tracking to store:**
```typescript
// src/store/projectStore.ts (or similar)
interface ProjectState {
// ... existing
isDirty: boolean;
lastSavedState: string | null; // JSON snapshot
markDirty: () => void;
markClean: () => void;
checkDirty: () => boolean;
}
// On any change to sites/settings:
markDirty()
// On save:
markClean()
lastSavedState = JSON.stringify(currentState)
```
**Browser beforeunload warning:**
```typescript
// src/App.tsx or dedicated hook
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (store.isDirty) {
e.preventDefault();
e.returnValue = ''; // Required for Chrome
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
```
---
### 2. Confirmation Dialogs
**Create reusable ConfirmDialog component:**
```typescript
// src/components/ui/ConfirmDialog.tsx
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string; // default: "Confirm"
cancelText?: string; // default: "Cancel"
variant?: 'danger' | 'warning' | 'info';
onConfirm: () => void;
onCancel: () => void;
}
// Styling:
// - danger: red confirm button (for delete)
// - warning: yellow/orange (for discard changes)
// - info: blue (for info confirmations)
```
**Apply to actions:**
| Action | Dialog |
|--------|--------|
| Load project (when dirty) | "Є незбережені зміни. Завантажити інший проект?" |
| Delete project | "Видалити проект '{name}'? Цю дію не можна скасувати." |
| Delete site | "Видалити станцію '{name}'?" |
| Delete sector | "Видалити сектор '{name}'?" |
| New project (when dirty) | "Є незбережені зміни. Створити новий проект?" |
| Page refresh (handled by beforeunload) | Browser native dialog |
---
### 3. Undo/Redo System
**Create history store:**
```typescript
// src/store/historyStore.ts
interface HistoryState {
past: ProjectSnapshot[];
future: ProjectSnapshot[];
maxHistory: number; // default: 50
// Actions
push: (snapshot: ProjectSnapshot) => void;
undo: () => ProjectSnapshot | null;
redo: () => ProjectSnapshot | null;
clear: () => void;
// Selectors
canUndo: boolean;
canRedo: boolean;
}
type ProjectSnapshot = {
sites: Site[];
settings: CoverageSettings;
timestamp: number;
action: string; // "add site", "delete sector", "move site", etc.
};
```
**Integration points — push snapshot BEFORE these actions:**
- Add site
- Delete site
- Update site (position, params)
- Add sector
- Delete sector
- Update sector
- Update coverage settings
- Import project
- Clear all sites
**Keyboard shortcuts:**
```typescript
// src/hooks/useKeyboardShortcuts.ts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
handleUndo();
}
// Redo: Ctrl+Shift+Z or Ctrl+Y
if ((e.ctrlKey || e.metaKey) && (
(e.key === 'z' && e.shiftKey) ||
e.key === 'y'
)) {
e.preventDefault();
handleRedo();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
```
**UI indicators:**
```typescript
// Toolbar buttons (disabled when can't undo/redo)
<button
onClick={handleUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
<UndoIcon />
</button>
<button
onClick={handleRedo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
>
<RedoIcon />
</button>
```
---
### 4. Enhanced Toast Notifications
**Extend existing toast for all actions:**
```typescript
// Success toasts
"Проект збережено"
"Станцію додано"
"Станцію видалено"
"Зміни скасовано" (undo)
"Зміни повернено" (redo)
// Error toasts
"Помилка збереження"
"Помилка завантаження"
// Info toasts
"Проект завантажено"
```
**Toast with undo action (optional enhancement):**
```typescript
// When deleting, show toast with undo button
toast({
message: "Станцію видалено",
action: {
label: "Скасувати",
onClick: () => handleUndo()
},
duration: 5000
});
```
---
## 📁 Files to Create/Modify
```
src/
├── components/ui/
│ └── ConfirmDialog.tsx # NEW
├── store/
│ ├── historyStore.ts # NEW
│ └── projectStore.ts # MODIFY (add isDirty)
├── hooks/
│ ├── useUnsavedChanges.ts # NEW
│ └── useKeyboardShortcuts.ts # NEW or MODIFY
└── App.tsx # MODIFY (add beforeunload)
```
---
## ✅ Success Criteria
- [ ] Refresh page with unsaved changes → browser warning
- [ ] Load project with unsaved changes → confirmation dialog
- [ ] Delete project → confirmation dialog (danger style)
- [ ] Delete site/sector → confirmation dialog
- [ ] Ctrl+Z undoes last action
- [ ] Ctrl+Shift+Z redoes
- [ ] Undo/Redo buttons in toolbar (with disabled states)
- [ ] Toast shows on save/delete/undo/redo
- [ ] History limited to 50 states (memory management)
---
## 🧪 Test Scenarios
1. **Unsaved changes:**
- Add site → refresh → browser warning appears
- Add site → save → refresh → no warning
2. **Confirmations:**
- Add site → click Load → dialog appears → Cancel → site still there
- Add site → click Load → dialog appears → Confirm → new project loads
3. **Undo/Redo:**
- Add 3 sites → Ctrl+Z → 2 sites remain
- Ctrl+Z again → 1 site
- Ctrl+Shift+Z → 2 sites back
- Add new site after undo → redo history cleared
4. **Edge cases:**
- Undo when nothing to undo → button disabled, no action
- 51 actions → oldest dropped from history
---
## 📝 Notes
- Keep snapshots lightweight (only sites + settings, not UI state)
- Debounce position changes (don't snapshot every pixel of drag)
- Consider grouping rapid changes (e.g., typing in input)
- Ukrainian UI text for all dialogs/toasts
---
**Ready for Claude Code** 🚀

View File

@@ -0,0 +1,653 @@
# RFCP Backend - Iteration 1.2: Terrain Integration
**Date:** January 30, 2025
**Type:** Backend Development
**Estimated:** 4-6 hours (Phase 2.1 + 2.2 from roadmap)
**Location:** `/opt/rfcp/backend/`
---
## 🎯 Goal
Add SRTM elevation data service and Line-of-Sight calculations for realistic RF coverage.
---
## 📋 Pre-reading (IMPORTANT)
Before starting, read these documents in project knowledge:
1. `RFCP-Backend-Roadmap-Complete.md` — Phase 2 details (lines 312-600)
2. `RFCP-Iteration-1.1-Backend-Foundation.md` — current state
---
## 📊 Current State
```bash
# Backend running with 1.1 complete
systemctl status rfcp-backend.service # ✅ active
# Structure from 1.1
/opt/rfcp/backend/
├── app/
│ ├── main.py
│ ├── api/routes/ # health.py, projects.py
│ ├── core/ # config.py, database.py
│ └── models/ # project.py, site.py
├── venv/
└── requirements.txt
```
---
## ✅ Tasks
### 1. Install Additional Dependencies
```bash
cd /opt/rfcp/backend
source venv/bin/activate
pip install aiofiles httpx
pip freeze > requirements.txt
```
---
### 2. Create Data Directory
```bash
mkdir -p /opt/rfcp/backend/data/srtm
chown -R root:root /opt/rfcp/backend/data
```
---
### 3. Create Terrain Service
**app/services/__init__.py:**
```python
# empty file
```
**app/services/terrain_service.py:**
```python
import struct
import asyncio
import aiofiles
import httpx
from pathlib import Path
from typing import List, Optional, Tuple
import numpy as np
class TerrainService:
"""
SRTM elevation data service
- Downloads and caches .hgt tiles
- Provides elevation lookups
- Generates elevation profiles
"""
# SRTM tile dimensions (1 arc-second = 3601x3601, 3 arc-second = 1201x1201)
TILE_SIZE = 3601 # 1 arc-second (30m resolution)
# Mirror URLs for SRTM data (USGS requires login, use mirrors)
SRTM_MIRRORS = [
"https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
"https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_dir}/{tile_name}.hgt.gz",
]
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/srtm"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True, parents=True)
self._tile_cache: dict[str, np.ndarray] = {} # In-memory cache
self._max_cached_tiles = 10 # Limit memory usage
def get_tile_name(self, lat: float, lon: float) -> str:
"""Convert lat/lon to SRTM tile name (e.g., N48E035)"""
lat_int = int(lat) if lat >= 0 else int(lat) - 1
lon_int = int(lon) if lon >= 0 else int(lon) - 1
lat_letter = 'N' if lat_int >= 0 else 'S'
lon_letter = 'E' if lon_int >= 0 else 'W'
return f"{lat_letter}{abs(lat_int):02d}{lon_letter}{abs(lon_int):03d}"
def get_tile_path(self, tile_name: str) -> Path:
"""Get local path for tile"""
return self.cache_dir / f"{tile_name}.hgt"
async def download_tile(self, tile_name: str) -> bool:
"""Download SRTM tile from mirror"""
import gzip
tile_path = self.get_tile_path(tile_name)
if tile_path.exists():
return True
lat_dir = tile_name[:3] # e.g., "N48"
async with httpx.AsyncClient(timeout=60.0) as client:
for mirror in self.SRTM_MIRRORS:
url = mirror.format(lat_dir=lat_dir, tile_name=tile_name)
try:
response = await client.get(url)
if response.status_code == 200:
# Decompress gzip
decompressed = gzip.decompress(response.content)
async with aiofiles.open(tile_path, 'wb') as f:
await f.write(decompressed)
print(f"Downloaded {tile_name} from {mirror}")
return True
except Exception as e:
print(f"Failed to download from {mirror}: {e}")
continue
print(f"Failed to download tile {tile_name}")
return False
async def load_tile(self, tile_name: str) -> Optional[np.ndarray]:
"""Load tile into memory (with caching)"""
# Check memory cache
if tile_name in self._tile_cache:
return self._tile_cache[tile_name]
tile_path = self.get_tile_path(tile_name)
# Download if missing
if not tile_path.exists():
success = await self.download_tile(tile_name)
if not success:
return None
# Read HGT file (big-endian signed 16-bit integers)
try:
async with aiofiles.open(tile_path, 'rb') as f:
data = await f.read()
# Parse as numpy array
arr = np.frombuffer(data, dtype='>i2').reshape(self.TILE_SIZE, self.TILE_SIZE)
# Manage cache size
if len(self._tile_cache) >= self._max_cached_tiles:
# Remove oldest entry
oldest = next(iter(self._tile_cache))
del self._tile_cache[oldest]
self._tile_cache[tile_name] = arr
return arr
except Exception as e:
print(f"Error loading tile {tile_name}: {e}")
return None
async def get_elevation(self, lat: float, lon: float) -> float:
"""Get elevation at specific coordinate (meters above sea level)"""
tile_name = self.get_tile_name(lat, lon)
tile = await self.load_tile(tile_name)
if tile is None:
return 0.0 # No data, assume sea level
# Calculate position within tile
lat_int = int(lat) if lat >= 0 else int(lat) - 1
lon_int = int(lon) if lon >= 0 else int(lon) - 1
lat_frac = lat - lat_int
lon_frac = lon - lon_int
# Row 0 = north edge, row 3600 = south edge
row = int((1 - lat_frac) * (self.TILE_SIZE - 1))
col = int(lon_frac * (self.TILE_SIZE - 1))
# Clamp to valid range
row = max(0, min(row, self.TILE_SIZE - 1))
col = max(0, min(col, self.TILE_SIZE - 1))
elevation = tile[row, col]
# -32768 = void/no data
if elevation == -32768:
return 0.0
return float(elevation)
async def get_elevation_profile(
self,
lat1: float, lon1: float,
lat2: float, lon2: float,
num_points: int = 100
) -> List[dict]:
"""
Get elevation profile between two points
Returns list of {lat, lon, elevation, distance} dicts
"""
lats = np.linspace(lat1, lat2, num_points)
lons = np.linspace(lon1, lon2, num_points)
# Calculate cumulative distances
total_distance = self.haversine_distance(lat1, lon1, lat2, lon2)
distances = np.linspace(0, total_distance, num_points)
profile = []
for i, (lat, lon, dist) in enumerate(zip(lats, lons, distances)):
elev = await self.get_elevation(lat, lon)
profile.append({
"lat": float(lat),
"lon": float(lon),
"elevation": elev,
"distance": float(dist)
})
return profile
@staticmethod
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points in meters"""
EARTH_RADIUS = 6371000 # meters
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2
c = 2 * np.arcsin(np.sqrt(a))
return EARTH_RADIUS * c
# Singleton instance
terrain_service = TerrainService()
```
---
### 4. Create Line-of-Sight Service
**app/services/los_service.py:**
```python
import numpy as np
from typing import Tuple, List
from app.services.terrain_service import terrain_service, TerrainService
class LineOfSightService:
"""
Line-of-Sight calculations with terrain
"""
EARTH_RADIUS = 6371000 # meters
K_FACTOR = 4/3 # Standard atmospheric refraction
def __init__(self, terrain: TerrainService = None):
self.terrain = terrain or terrain_service
async def check_line_of_sight(
self,
tx_lat: float, tx_lon: float, tx_height: float,
rx_lat: float, rx_lon: float, rx_height: float = 1.5,
num_samples: int = 50
) -> dict:
"""
Check line-of-sight between transmitter and receiver
Args:
tx_lat, tx_lon: Transmitter coordinates
tx_height: Transmitter antenna height above ground (meters)
rx_lat, rx_lon: Receiver coordinates
rx_height: Receiver height above ground (meters), default 1.5m (person)
num_samples: Number of points to sample along path
Returns:
{
"has_los": bool,
"clearance": float, # minimum clearance in meters (negative = blocked)
"blocked_at": float | None, # distance where blocked (meters)
"profile": [...] # elevation profile with LOS line
}
"""
# Get elevation profile
profile = await self.terrain.get_elevation_profile(
tx_lat, tx_lon, rx_lat, rx_lon, num_samples
)
if not profile:
return {"has_los": True, "clearance": 0, "blocked_at": None, "profile": []}
# Get endpoint elevations
tx_ground = profile[0]["elevation"]
rx_ground = profile[-1]["elevation"]
tx_total = tx_ground + tx_height
rx_total = rx_ground + rx_height
total_distance = profile[-1]["distance"]
min_clearance = float('inf')
blocked_at = None
# Check each point along path
for point in profile:
d = point["distance"]
terrain_elev = point["elevation"]
if total_distance == 0:
los_height = tx_total
else:
# Linear interpolation of LOS line
los_height = tx_total + (rx_total - tx_total) * (d / total_distance)
# Earth curvature correction (with atmospheric refraction)
# Effective Earth radius = K * actual radius
effective_radius = self.K_FACTOR * self.EARTH_RADIUS
curvature = (d * (total_distance - d)) / (2 * effective_radius)
# LOS height after curvature correction
los_height_corrected = los_height - curvature
# Clearance at this point
clearance = los_height_corrected - terrain_elev
# Add to profile for visualization
point["los_height"] = los_height_corrected
point["clearance"] = clearance
if clearance < min_clearance:
min_clearance = clearance
if clearance <= 0:
blocked_at = d
has_los = min_clearance > 0
return {
"has_los": has_los,
"clearance": min_clearance,
"blocked_at": blocked_at,
"profile": profile
}
async def calculate_fresnel_clearance(
self,
tx_lat: float, tx_lon: float, tx_height: float,
rx_lat: float, rx_lon: float, rx_height: float,
frequency_mhz: float,
num_samples: int = 50
) -> dict:
"""
Calculate Fresnel zone clearance
60% clearance of 1st Fresnel zone = good signal
Returns:
{
"clearance_percent": float, # worst-case clearance as % of required
"has_adequate_clearance": bool, # >= 60%
"worst_point_distance": float,
"fresnel_profile": [...]
}
"""
profile = await self.terrain.get_elevation_profile(
tx_lat, tx_lon, rx_lat, rx_lon, num_samples
)
if not profile:
return {
"clearance_percent": 100.0,
"has_adequate_clearance": True,
"worst_point_distance": 0,
"fresnel_profile": []
}
tx_ground = profile[0]["elevation"]
rx_ground = profile[-1]["elevation"]
tx_total = tx_ground + tx_height
rx_total = rx_ground + rx_height
total_distance = profile[-1]["distance"]
# Wavelength (λ = c / f)
wavelength = 300.0 / frequency_mhz # meters
worst_clearance_pct = 100.0
worst_distance = 0.0
for point in profile:
d = point["distance"]
terrain_elev = point["elevation"]
if d == 0 or d == total_distance:
continue # Skip endpoints
# LOS height at this point
if total_distance > 0:
los_height = tx_total + (rx_total - tx_total) * (d / total_distance)
else:
los_height = tx_total
# 1st Fresnel zone radius at this point
d1 = d
d2 = total_distance - d
fresnel_radius = np.sqrt((wavelength * d1 * d2) / total_distance)
# Required clearance (60% of 1st Fresnel zone)
required_clearance = 0.6 * fresnel_radius
# Actual clearance
actual_clearance = los_height - terrain_elev
# Clearance as percentage of required
if required_clearance > 0:
clearance_pct = (actual_clearance / required_clearance) * 100
else:
clearance_pct = 100.0
# Add to profile
point["fresnel_radius"] = fresnel_radius
point["required_clearance"] = required_clearance
point["clearance_percent"] = clearance_pct
if clearance_pct < worst_clearance_pct:
worst_clearance_pct = clearance_pct
worst_distance = d
return {
"clearance_percent": worst_clearance_pct,
"has_adequate_clearance": worst_clearance_pct >= 60.0,
"worst_point_distance": worst_distance,
"fresnel_profile": profile
}
# Singleton instance
los_service = LineOfSightService()
```
---
### 5. Create Terrain API Routes
**app/api/routes/terrain.py:**
```python
from fastapi import APIRouter, HTTPException, Query
from typing import Optional
from app.services.terrain_service import terrain_service
from app.services.los_service import los_service
router = APIRouter()
@router.get("/elevation")
async def get_elevation(
lat: float = Query(..., ge=-90, le=90, description="Latitude"),
lon: float = Query(..., ge=-180, le=180, description="Longitude")
):
"""Get elevation at a specific point"""
elevation = await terrain_service.get_elevation(lat, lon)
return {
"lat": lat,
"lon": lon,
"elevation": elevation,
"unit": "meters"
}
@router.get("/profile")
async def get_elevation_profile(
lat1: float = Query(..., description="Start latitude"),
lon1: float = Query(..., description="Start longitude"),
lat2: float = Query(..., description="End latitude"),
lon2: float = Query(..., description="End longitude"),
points: int = Query(100, ge=10, le=500, description="Number of sample points")
):
"""Get elevation profile between two points"""
profile = await terrain_service.get_elevation_profile(lat1, lon1, lat2, lon2, points)
return {
"start": {"lat": lat1, "lon": lon1},
"end": {"lat": lat2, "lon": lon2},
"num_points": len(profile),
"profile": profile
}
@router.get("/los")
async def check_line_of_sight(
tx_lat: float = Query(..., description="Transmitter latitude"),
tx_lon: float = Query(..., description="Transmitter longitude"),
tx_height: float = Query(..., ge=0, description="Transmitter height above ground (m)"),
rx_lat: float = Query(..., description="Receiver latitude"),
rx_lon: float = Query(..., description="Receiver longitude"),
rx_height: float = Query(1.5, ge=0, description="Receiver height above ground (m)")
):
"""Check line-of-sight between transmitter and receiver"""
result = await los_service.check_line_of_sight(
tx_lat, tx_lon, tx_height,
rx_lat, rx_lon, rx_height
)
return result
@router.get("/fresnel")
async def check_fresnel_clearance(
tx_lat: float = Query(..., description="Transmitter latitude"),
tx_lon: float = Query(..., description="Transmitter longitude"),
tx_height: float = Query(..., ge=0, description="Transmitter height (m)"),
rx_lat: float = Query(..., description="Receiver latitude"),
rx_lon: float = Query(..., description="Receiver longitude"),
rx_height: float = Query(1.5, ge=0, description="Receiver height (m)"),
frequency: float = Query(..., ge=100, le=6000, description="Frequency (MHz)")
):
"""Calculate Fresnel zone clearance"""
result = await los_service.calculate_fresnel_clearance(
tx_lat, tx_lon, tx_height,
rx_lat, rx_lon, rx_height,
frequency
)
return result
@router.get("/tiles")
async def list_cached_tiles():
"""List cached SRTM tiles"""
tiles = list(terrain_service.cache_dir.glob("*.hgt"))
return {
"cache_dir": str(terrain_service.cache_dir),
"tiles": [t.stem for t in tiles],
"count": len(tiles)
}
```
---
### 6. Register Routes in main.py
**Update app/main.py:**
```python
# Add import
from app.api.routes import health, projects, terrain
# Add router
app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"])
```
---
### 7. Restart & Test
```bash
# Install deps
cd /opt/rfcp/backend
source venv/bin/activate
pip install aiofiles httpx
# Restart service
sudo systemctl restart rfcp-backend.service
# Check logs
journalctl -u rfcp-backend -f
# Test endpoints
curl "https://api.rfcp.eliah.one/api/terrain/elevation?lat=48.5&lon=35.0"
curl "https://api.rfcp.eliah.one/api/terrain/profile?lat1=48.5&lon1=35.0&lat2=48.6&lon2=35.1&points=50"
curl "https://api.rfcp.eliah.one/api/terrain/los?tx_lat=48.5&tx_lon=35.0&tx_height=30&rx_lat=48.52&rx_lon=35.02"
curl "https://api.rfcp.eliah.one/api/terrain/fresnel?tx_lat=48.5&tx_lon=35.0&tx_height=30&rx_lat=48.52&rx_lon=35.02&frequency=1800"
```
---
## ✅ Success Criteria
- [ ] `/api/terrain/elevation` returns elevation for any Ukraine coordinate
- [ ] SRTM tiles auto-download to `/opt/rfcp/backend/data/srtm/`
- [ ] `/api/terrain/profile` returns elevation array between two points
- [ ] `/api/terrain/los` returns `has_los: true/false` with clearance
- [ ] `/api/terrain/fresnel` returns clearance percentage
- [ ] In-memory tile cache working (≤10 tiles)
- [ ] Swagger docs show all new endpoints
---
## 📁 Files Created
```
app/services/
├── __init__.py # NEW
├── terrain_service.py # NEW - SRTM handling
└── los_service.py # NEW - LoS + Fresnel
app/api/routes/
└── terrain.py # NEW - API endpoints
data/srtm/
└── *.hgt # Auto-downloaded tiles
```
---
## 📝 Notes
- SRTM tiles ~25MB each, auto-download on first request
- Ukraine needs ~20-30 tiles for full coverage
- First request to new area will be slow (download)
- Subsequent requests fast (cached in memory + disk)
- Earth curvature + atmospheric refraction (K=4/3) included
---
## 🔜 Next: Iteration 1.3
- Integrate terrain into coverage calculation
- Add `use_terrain` flag to settings
- Enhanced path loss with LoS/Fresnel factors
---
**Ready for Claude Code** 🚀

View File

@@ -0,0 +1,855 @@
# RFCP Backend - Iteration 1.3: Coverage Calculation + OSM Buildings
**Date:** January 30, 2025
**Type:** Backend Development
**Estimated:** 6-8 hours
**Location:** `/opt/rfcp/backend/`
---
## 🎯 Goal
Implement server-side coverage calculation with terrain (SRTM) and building obstacles (OpenStreetMap) for realistic urban RF propagation.
---
## 📋 Pre-reading
1. `RFCP-Backend-Roadmap-Complete.md` — Phase 2 & 3 details
2. `RFCP-Iteration-1.2-Terrain-Integration.md` — current terrain services
---
## 📊 Current State
```bash
# Backend 1.2 complete
/opt/rfcp/backend/app/
├── services/
│ ├── terrain_service.py # SRTM elevation ✅
│ └── los_service.py # Line-of-sight + Fresnel ✅
├── api/routes/
│ └── terrain.py # /elevation, /profile, /los, /fresnel ✅
```
**What's missing:**
- Building data (OSM)
- Coverage grid calculation
- Integration of terrain + buildings into RF model
---
## ✅ Tasks
### 1. Create OSM Buildings Service
**app/services/buildings_service.py:**
```python
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()
```
---
### 2. Create Coverage Calculation Service
**app/services/coverage_service.py:**
```python
import numpy as np
import asyncio
from typing import List, Optional, Tuple
from pydantic import BaseModel
from app.services.terrain_service import terrain_service, TerrainService
from app.services.los_service import los_service
from app.services.buildings_service import buildings_service, Building
class CoveragePoint(BaseModel):
lat: float
lon: float
rsrp: float # dBm
distance: float # meters from site
has_los: bool
terrain_loss: float # dB
building_loss: float # dB
class CoverageSettings(BaseModel):
radius: float = 10000 # meters
resolution: float = 200 # meters
min_signal: float = -120 # dBm threshold
use_terrain: bool = True
use_buildings: bool = True
class SiteParams(BaseModel):
lat: float
lon: float
height: float = 30 # antenna height meters
power: float = 43 # dBm (20W)
gain: float = 15 # dBi
frequency: float = 1800 # MHz
azimuth: Optional[float] = None # degrees, None = omni
beamwidth: Optional[float] = 65 # degrees
class CoverageService:
"""
RF Coverage calculation with terrain and buildings
"""
EARTH_RADIUS = 6371000
def __init__(self):
self.terrain = terrain_service
self.buildings = buildings_service
self.los = los_service
async def calculate_coverage(
self,
site: SiteParams,
settings: CoverageSettings
) -> List[CoveragePoint]:
"""
Calculate coverage grid for a single site
Returns list of CoveragePoint with RSRP values
"""
points = []
# Generate grid
grid = self._generate_grid(
site.lat, site.lon,
settings.radius,
settings.resolution
)
# Fetch buildings for coverage area (if enabled)
buildings = []
if settings.use_buildings:
# Calculate bbox with margin
lat_delta = settings.radius / 111000 # ~111km per degree
lon_delta = settings.radius / (111000 * np.cos(np.radians(site.lat)))
buildings = await self.buildings.fetch_buildings(
site.lat - lat_delta, site.lon - lon_delta,
site.lat + lat_delta, site.lon + lon_delta
)
# Calculate coverage for each point
for lat, lon in grid:
point = await self._calculate_point(
site, lat, lon,
settings, buildings
)
if point.rsrp >= settings.min_signal:
points.append(point)
return points
async def calculate_multi_site_coverage(
self,
sites: List[SiteParams],
settings: CoverageSettings
) -> List[CoveragePoint]:
"""
Calculate combined coverage from multiple sites
Best server (strongest signal) wins at each point
"""
if not sites:
return []
# Get all individual coverages
all_coverages = await asyncio.gather(*[
self.calculate_coverage(site, settings)
for site in sites
])
# Combine by best signal
point_map: dict[Tuple[float, float], CoveragePoint] = {}
for coverage in all_coverages:
for point in coverage:
key = (round(point.lat, 6), round(point.lon, 6))
if key not in point_map or point.rsrp > point_map[key].rsrp:
point_map[key] = point
return list(point_map.values())
def _generate_grid(
self,
center_lat: float, center_lon: float,
radius: float, resolution: float
) -> List[Tuple[float, float]]:
"""Generate coverage grid points"""
points = []
# Convert resolution to degrees
lat_step = resolution / 111000
lon_step = resolution / (111000 * np.cos(np.radians(center_lat)))
# Calculate grid bounds
lat_delta = radius / 111000
lon_delta = radius / (111000 * np.cos(np.radians(center_lat)))
lat = center_lat - lat_delta
while lat <= center_lat + lat_delta:
lon = center_lon - lon_delta
while lon <= center_lon + lon_delta:
# Check if within radius (circular, not square)
dist = TerrainService.haversine_distance(center_lat, center_lon, lat, lon)
if dist <= radius:
points.append((lat, lon))
lon += lon_step
lat += lat_step
return points
async def _calculate_point(
self,
site: SiteParams,
lat: float, lon: float,
settings: CoverageSettings,
buildings: List[Building]
) -> CoveragePoint:
"""Calculate RSRP at a single point"""
# Distance
distance = TerrainService.haversine_distance(site.lat, site.lon, lat, lon)
if distance < 1:
distance = 1 # Avoid division by zero
# Base path loss (Okumura-Hata for urban)
path_loss = self._okumura_hata(
distance, site.frequency, site.height, 1.5 # 1.5m receiver height
)
# Antenna pattern loss (if directional)
antenna_loss = 0.0
if site.azimuth is not None and site.beamwidth:
antenna_loss = self._antenna_pattern_loss(
site.lat, site.lon, lat, lon,
site.azimuth, site.beamwidth
)
# Terrain loss (LoS check)
terrain_loss = 0.0
has_los = True
if settings.use_terrain:
los_result = await self.los.check_line_of_sight(
site.lat, site.lon, site.height,
lat, lon, 1.5 # receiver at 1.5m
)
has_los = los_result["has_los"]
if not has_los:
# Add diffraction loss based on clearance
clearance = los_result["clearance"]
terrain_loss = self._diffraction_loss(clearance, site.frequency)
# Building loss
building_loss = 0.0
if settings.use_buildings and buildings:
for building in buildings:
intersection = self.buildings.line_intersects_building(
site.lat, site.lon, site.height + await self.terrain.get_elevation(site.lat, site.lon),
lat, lon, 1.5 + await self.terrain.get_elevation(lat, lon),
building
)
if intersection is not None:
# Building penetration loss (~20dB for concrete)
building_loss += 20.0
has_los = False
break # One building is enough
# Calculate RSRP
# RSRP = Tx Power + Tx Gain - Path Loss - Antenna Loss - Terrain Loss - Building Loss
rsrp = site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss
return CoveragePoint(
lat=lat,
lon=lon,
rsrp=rsrp,
distance=distance,
has_los=has_los,
terrain_loss=terrain_loss,
building_loss=building_loss
)
def _okumura_hata(
self,
distance: float, # meters
frequency: float, # MHz
tx_height: float, # meters
rx_height: float # meters
) -> float:
"""
Okumura-Hata path loss model (urban)
Returns path loss in dB
"""
d_km = distance / 1000
if d_km < 0.1:
d_km = 0.1 # Minimum distance
# Mobile antenna height correction (urban)
a_hm = (1.1 * np.log10(frequency) - 0.7) * rx_height - (1.56 * np.log10(frequency) - 0.8)
# Path loss
L = (69.55 + 26.16 * np.log10(frequency) - 13.82 * np.log10(tx_height) - a_hm +
(44.9 - 6.55 * np.log10(tx_height)) * np.log10(d_km))
return L
def _antenna_pattern_loss(
self,
site_lat: float, site_lon: float,
point_lat: float, point_lon: float,
azimuth: float, beamwidth: float
) -> float:
"""Calculate antenna pattern attenuation"""
# Calculate bearing from site to point
bearing = self._calculate_bearing(site_lat, site_lon, point_lat, point_lon)
# Angle difference from main lobe
angle_diff = abs(bearing - azimuth)
if angle_diff > 180:
angle_diff = 360 - angle_diff
# Simple cosine pattern approximation
# 3dB beamwidth = angle where power drops to half
half_beamwidth = beamwidth / 2
if angle_diff <= half_beamwidth:
# Within main lobe - minimal loss
loss = 3 * (angle_diff / half_beamwidth) ** 2
else:
# Outside main lobe - significant loss
loss = 3 + 12 * ((angle_diff - half_beamwidth) / half_beamwidth) ** 2
loss = min(loss, 25) # Cap at 25dB (back lobe)
return loss
def _calculate_bearing(
self,
lat1: float, lon1: float,
lat2: float, lon2: float
) -> float:
"""Calculate bearing from point 1 to point 2 (degrees)"""
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlon = lon2 - lon1
x = np.sin(dlon) * np.cos(lat2)
y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon)
bearing = np.degrees(np.arctan2(x, y))
return (bearing + 360) % 360
def _diffraction_loss(self, clearance: float, frequency: float) -> float:
"""
Knife-edge diffraction loss
Args:
clearance: Clearance in meters (negative = obstructed)
frequency: Frequency in MHz
Returns:
Additional loss in dB
"""
if clearance >= 0:
return 0.0 # No obstruction
# Fresnel parameter approximation
# v ≈ clearance * sqrt(2 / (λ * d))
# Simplified: use clearance directly
v = abs(clearance) / 10 # Normalize
# Knife-edge loss approximation
if v <= 0:
loss = 0
elif v < 2.4:
loss = 6.02 + 9.11 * v - 1.27 * v**2
else:
loss = 13.0 + 20 * np.log10(v)
return min(loss, 40) # Cap at 40dB
# Singleton
coverage_service = CoverageService()
```
---
### 3. Create Coverage API Routes
**app/api/routes/coverage.py:**
```python
from fastapi import APIRouter, HTTPException, BackgroundTasks
from typing import List, Optional
from pydantic import BaseModel
from app.services.coverage_service import (
coverage_service,
CoverageSettings,
SiteParams,
CoveragePoint
)
router = APIRouter()
class CoverageRequest(BaseModel):
"""Request body for coverage calculation"""
sites: List[SiteParams]
settings: CoverageSettings = CoverageSettings()
class CoverageResponse(BaseModel):
"""Coverage calculation response"""
points: List[CoveragePoint]
count: int
settings: CoverageSettings
stats: dict
@router.post("/calculate")
async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
"""
Calculate RF coverage for one or more sites
Returns grid of RSRP values with terrain and building effects
"""
if not request.sites:
raise HTTPException(400, "At least one site required")
if len(request.sites) > 10:
raise HTTPException(400, "Maximum 10 sites per request")
# Validate settings
if request.settings.radius > 50000:
raise HTTPException(400, "Maximum radius 50km")
if request.settings.resolution < 50:
raise HTTPException(400, "Minimum resolution 50m")
# Calculate
if len(request.sites) == 1:
points = await coverage_service.calculate_coverage(
request.sites[0],
request.settings
)
else:
points = await coverage_service.calculate_multi_site_coverage(
request.sites,
request.settings
)
# Calculate stats
rsrp_values = [p.rsrp for p in points]
los_count = sum(1 for p in points if p.has_los)
stats = {
"min_rsrp": min(rsrp_values) if rsrp_values else 0,
"max_rsrp": max(rsrp_values) if rsrp_values else 0,
"avg_rsrp": sum(rsrp_values) / len(rsrp_values) if rsrp_values else 0,
"los_percentage": (los_count / len(points) * 100) if points else 0,
"points_with_buildings": sum(1 for p in points if p.building_loss > 0),
"points_with_terrain_loss": sum(1 for p in points if p.terrain_loss > 0),
}
return CoverageResponse(
points=points,
count=len(points),
settings=request.settings,
stats=stats
)
@router.get("/buildings")
async def get_buildings(
min_lat: float,
min_lon: float,
max_lat: float,
max_lon: float
):
"""
Get buildings in bounding box (for debugging/visualization)
"""
from app.services.buildings_service import buildings_service
# Limit bbox size
if (max_lat - min_lat) > 0.1 or (max_lon - min_lon) > 0.1:
raise HTTPException(400, "Bbox too large (max 0.1 degrees)")
buildings = await buildings_service.fetch_buildings(
min_lat, min_lon, max_lat, max_lon
)
return {
"count": len(buildings),
"buildings": [b.model_dump() for b in buildings]
}
```
---
### 4. Register Routes
**Update app/main.py:**
```python
# Add import
from app.api.routes import health, projects, terrain, coverage
# Add router
app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"])
```
**Update version:**
```python
version="1.3.0"
```
---
### 5. Create Buildings Cache Directory
```bash
mkdir -p /opt/rfcp/backend/data/buildings
```
---
### 6. Test
```bash
# Restart
sudo systemctl restart rfcp-backend
# Test buildings endpoint
curl "https://api.rfcp.eliah.one/api/coverage/buildings?min_lat=48.45&min_lon=35.0&max_lat=48.47&max_lon=35.02"
# Test coverage calculation (single site)
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": 2000,
"resolution": 100,
"use_terrain": true,
"use_buildings": true
}
}'
```
---
## ✅ Success Criteria
- [ ] `/api/coverage/buildings` returns OSM buildings with heights
- [ ] Buildings cached to disk (check `/opt/rfcp/backend/data/buildings/`)
- [ ] `/api/coverage/calculate` returns coverage grid
- [ ] Response includes `terrain_loss` and `building_loss` per point
- [ ] Stats show `los_percentage` and building/terrain impact
- [ ] Swagger docs show new endpoints
---
## 📁 Files Created
```
app/services/
├── buildings_service.py # NEW - OSM Overpass integration
└── coverage_service.py # NEW - RF coverage calculation
app/api/routes/
└── coverage.py # NEW - API endpoints
data/buildings/
└── *.json # Cached building data per bbox
```
---
## 📝 Notes
- Overpass API has rate limits (~10k requests/day) — caching critical
- Building height estimation: `levels × 3m` or defaults by type
- Building penetration loss: ~20dB for concrete (simplified)
- Diffraction uses knife-edge approximation
- Coverage calculation can be slow for large areas — consider async/background tasks later
---
## 🔜 Next: Iteration 1.4
- Frontend integration (replace browser calculation with API)
- Real-time coverage updates
- Progress indication for large calculations
---
**Ready for Claude Code** 🚀

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,846 @@
# RFCP Iteration 1.5: Frontend ↔ Backend Integration
**Date:** January 31, 2025
**Type:** Full-Stack Integration
**Estimated:** 8-12 hours
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
---
## 🎯 Goal
Replace browser-based coverage calculation with backend API. Add propagation model selection UI and real-time progress indication.
---
## 📊 Current State
**Frontend:**
- Coverage calculated in Web Workers (browser)
- Settings stored in localStorage/IndexedDB
- No connection to backend API
- ~0.15s calculation time (simple model)
**Backend:**
- Full propagation engine ready (1.4)
- 4 presets (fast/standard/detailed/full)
- 6 propagation models available
- API at `https://api.rfcp.eliah.one`
**Gap:**
- Frontend doesn't call backend
- No model selection UI
- No progress indication for slow calculations
---
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────┐
│ Frontend │
├─────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Site Panel │───▶│ Coverage Settings Panel │ │
│ └─────────────┘ │ ├── Radius │ │
│ │ ├── Resolution │ │
│ │ ├── Preset [dropdown] │ NEW │
│ │ └── Model toggles │ NEW │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌──────────────────────────────▼────────────────┐ │
│ │ Coverage Service (API client) │ │
│ │ ├── POST /api/coverage/calculate │ │
│ │ ├── Progress polling / SSE │ │
│ │ └── Result caching │ │
│ └──────────────────────────────┬────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼────────────────┐ │
│ │ Map Visualization │ │
│ │ ├── Heatmap layer (existing) │ │
│ │ └── Model info overlay NEW │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────┐
│ Backend API │
│ POST /api/coverage/calculate │
│ GET /api/coverage/presets │
│ GET /api/terrain/elevation │
└─────────────────────────────────────────────────────┘
```
---
## ✅ Tasks
### Task 1.5.1: API Client Service (2-3 hours)
**frontend/src/services/api.ts:**
```typescript
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one';
export interface CoverageRequest {
sites: SiteParams[];
settings: CoverageSettings;
}
export interface SiteParams {
lat: number;
lon: number;
height: number;
power: number;
gain: number;
frequency: number;
azimuth?: number;
beamwidth?: number;
}
export interface CoverageSettings {
radius: number;
resolution: number;
min_signal: number;
preset?: 'fast' | 'standard' | 'detailed' | 'full';
use_terrain?: boolean;
use_buildings?: boolean;
use_materials?: boolean;
use_dominant_path?: boolean;
use_street_canyon?: boolean;
use_reflections?: boolean;
}
export interface CoveragePoint {
lat: number;
lon: number;
rsrp: number;
distance: number;
has_los: boolean;
terrain_loss: number;
building_loss: number;
reflection_gain: number;
}
export interface CoverageResponse {
points: CoveragePoint[];
count: number;
settings: CoverageSettings;
stats: CoverageStats;
computation_time: number;
models_used: string[];
}
export interface CoverageStats {
min_rsrp: number;
max_rsrp: number;
avg_rsrp: number;
los_percentage: number;
points_with_buildings: number;
points_with_terrain_loss: number;
points_with_reflection_gain: number;
}
export interface Preset {
description: string;
use_terrain: boolean;
use_buildings: boolean;
use_materials: boolean;
use_dominant_path: boolean;
use_street_canyon: boolean;
use_reflections: boolean;
estimated_speed: string;
}
class ApiService {
private abortController: AbortController | null = null;
async getPresets(): Promise<Record<string, Preset>> {
const response = await fetch(`${API_BASE}/api/coverage/presets`);
if (!response.ok) throw new Error('Failed to fetch presets');
const data = await response.json();
return data.presets;
}
async calculateCoverage(
request: CoverageRequest,
onProgress?: (progress: number) => void
): Promise<CoverageResponse> {
// Cancel previous request if running
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
const response = await fetch(`${API_BASE}/api/coverage/calculate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: this.abortController.signal
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Coverage calculation failed');
}
return response.json();
}
cancelCalculation() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
async getElevation(lat: number, lon: number): Promise<number> {
const response = await fetch(
`${API_BASE}/api/terrain/elevation?lat=${lat}&lon=${lon}`
);
if (!response.ok) return 0;
const data = await response.json();
return data.elevation;
}
}
export const api = new ApiService();
```
---
### Task 1.5.2: Coverage Settings Panel Update (3-4 hours)
**frontend/src/components/panels/CoverageSettingsPanel.tsx:**
```typescript
import { useState, useEffect } from 'react';
import { api, Preset } from '../../services/api';
import { useCoverageStore } from '../../store/coverage';
import { NumberInput } from '../ui/NumberInput';
import { Toggle } from '../ui/Toggle';
export function CoverageSettingsPanel() {
const { settings, updateSettings, isCalculating } = useCoverageStore();
const [presets, setPresets] = useState<Record<string, Preset>>({});
const [showAdvanced, setShowAdvanced] = useState(false);
// Load presets on mount
useEffect(() => {
api.getPresets().then(setPresets).catch(console.error);
}, []);
const handlePresetChange = (preset: string) => {
if (presets[preset]) {
updateSettings({
preset,
...presets[preset]
});
}
};
return (
<div className="coverage-settings-panel">
<h3>Coverage Settings</h3>
{/* Basic Settings */}
<NumberInput
label="Radius"
value={settings.radius / 1000}
onChange={(v) => updateSettings({ radius: v * 1000 })}
min={1}
max={100}
step={1}
unit="km"
tooltip="Coverage calculation radius around each site"
/>
<NumberInput
label="Resolution"
value={settings.resolution}
onChange={(v) => updateSettings({ resolution: v })}
min={50}
max={500}
step={50}
unit="m"
tooltip="Grid spacing — lower = more accurate but slower"
/>
<NumberInput
label="Min Signal"
value={settings.min_signal}
onChange={(v) => updateSettings({ min_signal: v })}
min={-140}
max={-50}
step={5}
unit="dBm"
tooltip="RSRP threshold — points below this are hidden"
/>
{/* Propagation Model Preset */}
<div className="setting-group">
<label>Propagation Model</label>
<select
value={settings.preset || 'standard'}
onChange={(e) => handlePresetChange(e.target.value)}
disabled={isCalculating}
>
{Object.entries(presets).map(([key, preset]) => (
<option key={key} value={key}>
{key.charAt(0).toUpperCase() + key.slice(1)} {preset.estimated_speed}
</option>
))}
</select>
{presets[settings.preset || 'standard'] && (
<p className="hint">
{presets[settings.preset || 'standard'].description}
</p>
)}
</div>
{/* Advanced Toggles */}
<div className="advanced-section">
<button
className="advanced-toggle"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? '▼' : '▶'} Advanced Options
</button>
{showAdvanced && (
<div className="advanced-options">
<Toggle
label="Terrain (SRTM)"
checked={settings.use_terrain}
onChange={(v) => updateSettings({ use_terrain: v })}
disabled={isCalculating}
/>
<Toggle
label="Buildings (OSM)"
checked={settings.use_buildings}
onChange={(v) => updateSettings({ use_buildings: v })}
disabled={isCalculating}
/>
<Toggle
label="Building Materials"
checked={settings.use_materials}
onChange={(v) => updateSettings({ use_materials: v })}
disabled={isCalculating || !settings.use_buildings}
/>
<Toggle
label="Dominant Path Analysis"
checked={settings.use_dominant_path}
onChange={(v) => updateSettings({ use_dominant_path: v })}
disabled={isCalculating}
/>
<Toggle
label="Street Canyon"
checked={settings.use_street_canyon}
onChange={(v) => updateSettings({ use_street_canyon: v })}
disabled={isCalculating}
/>
<Toggle
label="Reflections"
checked={settings.use_reflections}
onChange={(v) => updateSettings({ use_reflections: v })}
disabled={isCalculating}
/>
</div>
)}
</div>
</div>
);
}
```
---
### Task 1.5.3: Coverage Store Update (2-3 hours)
**frontend/src/store/coverage.ts:**
```typescript
import { create } from 'zustand';
import { api, CoverageResponse, CoverageSettings, CoveragePoint } from '../services/api';
import { useSitesStore } from './sites';
interface CoverageState {
// Data
points: CoveragePoint[];
stats: CoverageResponse['stats'] | null;
// Settings
settings: CoverageSettings;
// UI State
isCalculating: boolean;
progress: number;
error: string | null;
lastCalculation: {
time: number;
models: string[];
} | null;
// Actions
updateSettings: (settings: Partial<CoverageSettings>) => void;
calculateCoverage: () => Promise<void>;
cancelCalculation: () => void;
clearCoverage: () => void;
}
const DEFAULT_SETTINGS: CoverageSettings = {
radius: 10000,
resolution: 200,
min_signal: -100,
preset: 'standard',
use_terrain: true,
use_buildings: true,
use_materials: true,
use_dominant_path: false,
use_street_canyon: false,
use_reflections: false,
};
export const useCoverageStore = create<CoverageState>((set, get) => ({
points: [],
stats: null,
settings: DEFAULT_SETTINGS,
isCalculating: false,
progress: 0,
error: null,
lastCalculation: null,
updateSettings: (newSettings) => {
set((state) => ({
settings: { ...state.settings, ...newSettings }
}));
},
calculateCoverage: async () => {
const { settings } = get();
const sites = useSitesStore.getState().sites;
if (sites.length === 0) {
set({ error: 'No sites to calculate coverage for' });
return;
}
set({ isCalculating: true, progress: 0, error: null });
try {
// Convert sites to API format
const apiSites = sites.flatMap(site =>
site.sectors.map(sector => ({
lat: site.lat,
lon: site.lon,
height: sector.height,
power: 10 * Math.log10(sector.power * 1000), // W to dBm
gain: sector.gain,
frequency: sector.frequency,
azimuth: sector.antennaType === 'directional' ? sector.azimuth : undefined,
beamwidth: sector.antennaType === 'directional' ? sector.beamwidth : undefined,
}))
);
const response = await api.calculateCoverage({
sites: apiSites,
settings
});
set({
points: response.points,
stats: response.stats,
isCalculating: false,
progress: 100,
lastCalculation: {
time: response.computation_time,
models: response.models_used
}
});
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
set({ isCalculating: false, progress: 0 });
} else {
set({
isCalculating: false,
error: error instanceof Error ? error.message : 'Calculation failed',
progress: 0
});
}
}
},
cancelCalculation: () => {
api.cancelCalculation();
set({ isCalculating: false, progress: 0 });
},
clearCoverage: () => {
set({ points: [], stats: null, lastCalculation: null });
},
}));
```
---
### Task 1.5.4: Calculate Button & Progress (2-3 hours)
**frontend/src/components/CoverageButton.tsx:**
```typescript
import { useCoverageStore } from '../store/coverage';
import { useSitesStore } from '../store/sites';
export function CoverageButton() {
const {
isCalculating,
progress,
calculateCoverage,
cancelCalculation,
lastCalculation
} = useCoverageStore();
const sitesCount = useSitesStore((s) => s.sites.length);
if (isCalculating) {
return (
<div className="coverage-button calculating">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${progress}%` }}
/>
</div>
<button onClick={cancelCalculation} className="cancel-btn">
Cancel
</button>
</div>
);
}
return (
<div className="coverage-button">
<button
onClick={calculateCoverage}
disabled={sitesCount === 0}
className="calculate-btn"
>
Calculate Coverage
</button>
{lastCalculation && (
<span className="last-calc-info">
{lastCalculation.time.toFixed(1)}s {lastCalculation.models.length} models
</span>
)}
</div>
);
}
```
**CSS (coverage-button.css):**
```css
.coverage-button {
display: flex;
align-items: center;
gap: 12px;
}
.calculate-btn {
background: var(--primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.calculate-btn:hover:not(:disabled) {
background: var(--primary-dark);
}
.calculate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.coverage-button.calculating {
flex-direction: column;
gap: 8px;
}
.progress-bar {
width: 200px;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s;
}
.cancel-btn {
background: var(--danger);
color: white;
border: none;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
}
.last-calc-info {
font-size: 12px;
color: var(--text-secondary);
}
```
---
### Task 1.5.5: Update Heatmap to Use API Points (2-3 hours)
**frontend/src/components/map/CoverageLayer.tsx:**
```typescript
import { useEffect, useMemo } from 'react';
import { useMap } from 'react-leaflet';
import { useCoverageStore } from '../../store/coverage';
import { createHeatmapTiles } from '../../lib/heatmap';
export function CoverageLayer() {
const map = useMap();
const { points, settings } = useCoverageStore();
// Convert API points to heatmap format
const heatmapData = useMemo(() => {
if (!points.length) return [];
return points.map(p => ({
lat: p.lat,
lon: p.lon,
value: p.rsrp,
// Additional data for tooltips
hasLos: p.has_los,
terrainLoss: p.terrain_loss,
buildingLoss: p.building_loss,
reflectionGain: p.reflection_gain,
}));
}, [points]);
useEffect(() => {
if (!heatmapData.length) return;
const tileLayer = createHeatmapTiles(heatmapData, {
minSignal: settings.min_signal,
maxSignal: -50,
opacity: 0.7,
});
tileLayer.addTo(map);
return () => {
map.removeLayer(tileLayer);
};
}, [map, heatmapData, settings.min_signal]);
return null;
}
```
---
### Task 1.5.6: Stats Panel Update (1-2 hours)
**frontend/src/components/panels/StatsPanel.tsx:**
```typescript
import { useCoverageStore } from '../../store/coverage';
export function StatsPanel() {
const { stats, lastCalculation, points } = useCoverageStore();
if (!stats) {
return (
<div className="stats-panel empty">
<p>Calculate coverage to see statistics</p>
</div>
);
}
return (
<div className="stats-panel">
<h3>Coverage Statistics</h3>
<div className="stats-grid">
<div className="stat">
<span className="label">Points</span>
<span className="value">{points.length.toLocaleString()}</span>
</div>
<div className="stat">
<span className="label">Min RSRP</span>
<span className="value">{stats.min_rsrp.toFixed(1)} dBm</span>
</div>
<div className="stat">
<span className="label">Max RSRP</span>
<span className="value">{stats.max_rsrp.toFixed(1)} dBm</span>
</div>
<div className="stat">
<span className="label">Avg RSRP</span>
<span className="value">{stats.avg_rsrp.toFixed(1)} dBm</span>
</div>
<div className="stat">
<span className="label">Line of Sight</span>
<span className="value">{stats.los_percentage.toFixed(1)}%</span>
</div>
<div className="stat">
<span className="label">Terrain Affected</span>
<span className="value">{stats.points_with_terrain_loss}</span>
</div>
<div className="stat">
<span className="label">Building Affected</span>
<span className="value">{stats.points_with_buildings}</span>
</div>
<div className="stat">
<span className="label">With Reflections</span>
<span className="value">{stats.points_with_reflection_gain}</span>
</div>
</div>
{lastCalculation && (
<div className="calc-info">
<p>
Calculated in <strong>{lastCalculation.time.toFixed(1)}s</strong>
</p>
<p className="models">
Models: {lastCalculation.models.join(', ')}
</p>
</div>
)}
</div>
);
}
```
---
### Task 1.5.7: Environment Configuration (1 hour)
**frontend/.env.development:**
```env
VITE_API_URL=https://api.rfcp.eliah.one
```
**frontend/.env.production:**
```env
VITE_API_URL=https://api.rfcp.eliah.one
```
**frontend/.env.local (for local backend):**
```env
VITE_API_URL=http://localhost:8888
```
**frontend/vite.config.ts update:**
```typescript
export default defineConfig({
// ... existing config
define: {
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
}
});
```
---
## 🧪 Testing
```bash
# 1. Start backend (if not running)
sudo systemctl start rfcp-backend
# 2. Start frontend dev
cd /opt/rfcp/frontend
npm run dev
# 3. Test in browser
# - Create a site
# - Select "Fast" preset
# - Click Calculate Coverage
# - Verify heatmap appears
# - Check stats panel shows API data
# 4. Test presets
# - Switch to "Standard" - should take ~30s
# - Switch to "Full" (small radius!) - should take ~2min
# - Verify models_used changes
# 5. Test cancel
# - Start "Full" calculation
# - Click Cancel
# - Verify it stops
```
---
## ✅ Success Criteria
- [ ] Coverage calculated via backend API (not browser)
- [ ] Preset dropdown shows 4 options with descriptions
- [ ] Advanced toggles work independently
- [ ] Progress indication during calculation
- [ ] Cancel button stops calculation
- [ ] Stats panel shows API response data
- [ ] `computation_time` and `models_used` displayed
- [ ] Heatmap renders API points correctly
- [ ] Error handling for API failures
- [ ] Works with multiple sites/sectors
---
## 📁 Files Changed
```
frontend/src/
├── services/
│ └── api.ts # NEW - API client
├── store/
│ └── coverage.ts # MODIFIED - API integration
├── components/
│ ├── panels/
│ │ ├── CoverageSettingsPanel.tsx # MODIFIED - preset UI
│ │ └── StatsPanel.tsx # MODIFIED - API stats
│ ├── map/
│ │ └── CoverageLayer.tsx # MODIFIED - use API points
│ └── CoverageButton.tsx # NEW - calculate + progress
├── .env.development # NEW
├── .env.production # NEW
└── vite.config.ts # MODIFIED
```
---
## 📝 Notes
- Remove or disable old Web Worker calculation after integration confirmed
- Keep localStorage for sites/settings backup (offline fallback)
- Consider WebSocket for real progress updates (future enhancement)
- API timeout should be generous (5+ minutes for "full" preset)
---
## 🔜 Next
After 1.5 complete:
- **1.4.1** — Enhanced Environment (R-tree, water, vegetation)
- **1.4.2** — Extra Factors (weather, indoor)
- **2.1** — Desktop Installer
---
**Ready for Claude Code** 🚀

View File

@@ -0,0 +1,229 @@
# RFCP Iteration 1.5.1: Fixes & Boundaries
**Date:** January 31, 2025
**Type:** Bugfix & Polish
**Estimated:** 2-3 hours
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
---
## 🎯 Goal
Fix Fresnel endpoint 500 error, restore coverage boundary visualization, minor polish.
---
## 🐛 Issues to Fix
### 1. Fresnel Endpoint 500 Error
**Symptom:**
```bash
curl "/api/terrain/fresnel?tx_lat=48.46&tx_lon=35.05&tx_height=30&rx_lat=48.47&rx_lon=35.06&rx_height=1.5&frequency=1800"
# Returns: 500 Internal Server Error
```
**Location:** `backend/app/api/routes/terrain.py`
**Likely cause:** Missing `rx_height` default or async issue in `los_service.check_fresnel_clearance()`
**Fix:**
```python
@router.get("/fresnel")
async def check_fresnel(
tx_lat: float,
tx_lon: float,
tx_height: float,
rx_lat: float,
rx_lon: float,
rx_height: float = 1.5, # Default receiver height
frequency: float = 1800 # Default frequency MHz
):
try:
result = await los_service.check_fresnel_clearance(
tx_lat, tx_lon, tx_height,
rx_lat, rx_lon, rx_height,
frequency
)
return result
except Exception as e:
raise HTTPException(500, f"Fresnel calculation error: {str(e)}")
```
**Debug:** Check backend logs for actual error:
```bash
journalctl -u rfcp-backend -n 50 | grep -i error
```
---
### 2. Coverage Boundary Not Showing
**Symptom:** Boundary contour line (-100 dBm) not visible on map after API integration
**Location:** `frontend/src/components/map/CoverageBoundary.tsx`
**Likely cause:** Component expecting old data format, not new API response
**Check:**
1. Is `CoverageBoundary` still mounted in map?
2. Does it receive `points` from coverage store?
3. Is boundary calculation using correct field (`rsrp` vs old field name)?
**Fix approach:**
```typescript
// CoverageBoundary.tsx
import { useCoverageStore } from '../../store/coverage';
export function CoverageBoundary() {
const { points, settings } = useCoverageStore();
// Generate boundary from API points
const boundaryPoints = useMemo(() => {
if (!points.length) return [];
// Filter points near threshold
const threshold = settings.min_signal; // -100 dBm
const tolerance = 5; // ±5 dBm
return points
.filter(p => Math.abs(p.rsrp - threshold) < tolerance)
.map(p => [p.lat, p.lon] as [number, number]);
}, [points, settings.min_signal]);
// ... rest of boundary rendering
}
```
**Alternative:** Use convex hull or marching squares for proper contour:
```typescript
import { concaveman } from 'concaveman';
const boundaryPolygon = useMemo(() => {
const edgePoints = points
.filter(p => p.rsrp >= settings.min_signal && p.rsrp < settings.min_signal + 10)
.map(p => [p.lon, p.lat]);
if (edgePoints.length < 3) return null;
return concaveman(edgePoints, 2); // concavity factor
}, [points, settings.min_signal]);
```
---
### 3. Minor Polish
**a) Stats panel — show models used:**
```typescript
// StatsPanel.tsx
{lastCalculation && (
<div className="models-used">
<span className="label">Models:</span>
<div className="model-tags">
{lastCalculation.models.map(m => (
<span key={m} className="model-tag">{m}</span>
))}
</div>
</div>
)}
```
**b) Loading state during long calculations:**
```typescript
// Show elapsed time during calculation
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!isCalculating) {
setElapsed(0);
return;
}
const start = Date.now();
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - start) / 1000));
}, 1000);
return () => clearInterval(interval);
}, [isCalculating]);
// In render:
{isCalculating && (
<div className="calculating-status">
Calculating... {elapsed}s
</div>
)}
```
**c) Error toast for API failures:**
```typescript
// In coverage store calculateCoverage()
catch (error) {
const message = error instanceof Error ? error.message : 'Calculation failed';
toast.error(message); // If using toast library
set({ error: message, isCalculating: false });
}
```
---
## ✅ Tasks
- [ ] Fix Fresnel endpoint (backend)
- [ ] Debug and check logs for actual error
- [ ] Restore CoverageBoundary with API points
- [ ] Test boundary renders correctly
- [ ] Add elapsed time counter during calculation
- [ ] Add model tags to stats panel
- [ ] Test all presets still work
- [ ] Run integration test — should be 21/21
---
## 🧪 Testing
```bash
# 1. Test Fresnel fix
curl "https://api.rfcp.eliah.one/api/terrain/fresnel?tx_lat=48.46&tx_lon=35.05&tx_height=30&rx_lat=48.47&rx_lon=35.06&rx_height=1.5&frequency=1800"
# Should return: {"clearance_percent": ..., "has_adequate_clearance": ...}
# 2. Run integration test
./rfcp-integration-test.sh
# Should be 21/21
# 3. Visual test
# - Calculate coverage
# - Verify boundary line appears at -100 dBm edge
# - Verify elapsed time shows during calculation
```
---
## 📁 Files to Modify
```
backend/app/
├── api/routes/terrain.py # Fresnel fix
└── services/los_service.py # Check fresnel method
frontend/src/
├── components/
│ ├── map/CoverageBoundary.tsx # Fix boundary rendering
│ └── panels/
│ ├── CoverageStats.tsx # Add model tags
│ └── StatsPanel.tsx # Elapsed time
└── store/coverage.ts # Error handling
```
---
## 📝 Notes
- Boundary can use simple edge detection or proper contour algorithm
- `concaveman` is lightweight (~2KB) for concave hull
- Elapsed time helps user know calculation is progressing
---
**Quick iteration — should be fast** 🚀

View File

@@ -0,0 +1,862 @@
# 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** 🚀

View File

@@ -0,0 +1,549 @@
# RFCP Iteration 1.6.1: Extra Factors
**Date:** January 31, 2025
**Type:** Backend Enhancement
**Estimated:** 4-6 hours
**Location:** `/opt/rfcp/backend/`
**Priority:** Low — nice to have for realism
---
## 🎯 Goal
Add weather effects, indoor penetration loss, and atmospheric absorption for more realistic RF propagation modeling.
---
## ✅ Features
### 1. Rain Attenuation (ITU-R P.838)
**Theory:** Rain causes signal attenuation, especially at higher frequencies (>10 GHz). Even at LTE frequencies (700-2600 MHz), heavy rain can add 1-3 dB loss.
**app/services/weather_service.py:**
```python
from typing import Optional
import math
class WeatherService:
"""ITU-R P.838 rain attenuation model"""
# ITU-R P.838-3 coefficients for horizontal polarization
# Format: (frequency_GHz, k, alpha)
RAIN_COEFFICIENTS = {
0.7: (0.0000387, 0.912),
1.0: (0.0000887, 0.949),
1.8: (0.000292, 1.021),
2.1: (0.000425, 1.052),
2.6: (0.000683, 1.091),
3.5: (0.00138, 1.149),
5.0: (0.00361, 1.206),
10.0: (0.0245, 1.200),
20.0: (0.0906, 1.099),
30.0: (0.175, 1.021),
}
def calculate_rain_attenuation(
self,
frequency_mhz: float,
distance_km: float,
rain_rate: float, # mm/h
polarization: str = "horizontal"
) -> float:
"""
Calculate rain attenuation in dB
Args:
frequency_mhz: Frequency in MHz
distance_km: Path length in km
rain_rate: Rain rate in mm/h (0=none, 5=light, 25=moderate, 50=heavy)
polarization: "horizontal" or "vertical"
Returns:
Attenuation in dB
"""
if rain_rate <= 0:
return 0.0
freq_ghz = frequency_mhz / 1000
# Get interpolated coefficients
k, alpha = self._get_coefficients(freq_ghz)
# Specific attenuation (dB/km)
gamma_r = k * (rain_rate ** alpha)
# Effective path length reduction for longer paths
# Rain cells are typically 2-5 km
if distance_km > 2:
reduction_factor = 1 / (1 + distance_km / 35)
effective_distance = distance_km * reduction_factor
else:
effective_distance = distance_km
attenuation = gamma_r * effective_distance
return min(attenuation, 30.0) # Cap at 30 dB
def _get_coefficients(self, freq_ghz: float) -> tuple[float, float]:
"""Interpolate rain coefficients for frequency"""
freqs = sorted(self.RAIN_COEFFICIENTS.keys())
# Find surrounding frequencies
if freq_ghz <= freqs[0]:
return self.RAIN_COEFFICIENTS[freqs[0]]
if freq_ghz >= freqs[-1]:
return self.RAIN_COEFFICIENTS[freqs[-1]]
for i in range(len(freqs) - 1):
if freqs[i] <= freq_ghz <= freqs[i + 1]:
f1, f2 = freqs[i], freqs[i + 1]
k1, a1 = self.RAIN_COEFFICIENTS[f1]
k2, a2 = self.RAIN_COEFFICIENTS[f2]
# Linear interpolation
t = (freq_ghz - f1) / (f2 - f1)
k = k1 + t * (k2 - k1)
alpha = a1 + t * (a2 - a1)
return k, alpha
return self.RAIN_COEFFICIENTS[freqs[0]]
@staticmethod
def rain_rate_from_description(description: str) -> float:
"""Convert rain description to rate"""
rates = {
"none": 0.0,
"drizzle": 2.5,
"light": 5.0,
"moderate": 12.5,
"heavy": 25.0,
"very_heavy": 50.0,
"extreme": 100.0,
}
return rates.get(description.lower(), 0.0)
weather_service = WeatherService()
```
---
### 2. Indoor Penetration Loss (ITU-R P.2109)
**Theory:** Signals lose strength when entering buildings. Loss depends on building type, frequency, and wall materials.
**app/services/indoor_service.py:**
```python
from typing import Optional
import math
class IndoorService:
"""ITU-R P.2109 building entry loss model"""
# Building Entry Loss (BEL) by construction type at 2 GHz
# Format: (median_loss_dB, std_dev_dB)
BUILDING_TYPES = {
"none": (0.0, 0.0), # Outdoor only
"light": (8.0, 4.0), # Wood frame, large windows
"medium": (15.0, 6.0), # Brick, standard windows
"heavy": (22.0, 8.0), # Concrete, small windows
"basement": (30.0, 10.0), # Underground
"vehicle": (6.0, 3.0), # Inside car
"train": (20.0, 5.0), # Inside train
}
# Frequency correction factor (dB per octave above 2 GHz)
FREQ_CORRECTION = 2.5
def calculate_indoor_loss(
self,
frequency_mhz: float,
building_type: str = "medium",
floor_number: int = 0,
depth_m: float = 0.0,
) -> float:
"""
Calculate building entry/indoor penetration loss
Args:
frequency_mhz: Frequency in MHz
building_type: Type of building construction
floor_number: Floor number (0=ground, negative=basement)
depth_m: Distance from exterior wall in meters
Returns:
Loss in dB
"""
if building_type == "none":
return 0.0
base_loss, _ = self.BUILDING_TYPES.get(building_type, (15.0, 6.0))
# Frequency correction
freq_ghz = frequency_mhz / 1000
if freq_ghz > 2.0:
octaves = math.log2(freq_ghz / 2.0)
freq_correction = self.FREQ_CORRECTION * octaves
else:
freq_correction = 0.0
# Floor correction (higher floors = less loss due to better angle)
if floor_number > 0:
floor_correction = -1.5 * min(floor_number, 10)
elif floor_number < 0:
# Basement - additional loss
floor_correction = 5.0 * abs(floor_number)
else:
floor_correction = 0.0
# Depth correction (signal attenuates inside building)
# Approximately 0.5 dB per meter for first 10m
depth_correction = 0.5 * min(depth_m, 20)
total_loss = base_loss + freq_correction + floor_correction + depth_correction
return max(0.0, min(total_loss, 50.0)) # Clamp 0-50 dB
def calculate_outdoor_to_indoor_coverage(
self,
outdoor_rsrp: float,
building_type: str,
frequency_mhz: float,
) -> float:
"""Calculate expected indoor RSRP from outdoor signal"""
indoor_loss = self.calculate_indoor_loss(frequency_mhz, building_type)
return outdoor_rsrp - indoor_loss
indoor_service = IndoorService()
```
---
### 3. Atmospheric Absorption (ITU-R P.676)
**Theory:** Oxygen and water vapor absorb RF energy. Significant at mmWave (>10 GHz), minor at LTE bands.
**app/services/atmospheric_service.py:**
```python
import math
class AtmosphericService:
"""ITU-R P.676 atmospheric absorption model"""
# Simplified model for frequencies < 50 GHz
# Standard atmosphere: T=15°C, P=1013 hPa, humidity=50%
def calculate_atmospheric_loss(
self,
frequency_mhz: float,
distance_km: float,
temperature_c: float = 15.0,
humidity_percent: float = 50.0,
altitude_m: float = 0.0,
) -> float:
"""
Calculate atmospheric absorption loss
Args:
frequency_mhz: Frequency in MHz
distance_km: Path length in km
temperature_c: Temperature in Celsius
humidity_percent: Relative humidity (0-100)
altitude_m: Altitude above sea level
Returns:
Loss in dB
"""
freq_ghz = frequency_mhz / 1000
# Below 1 GHz - negligible
if freq_ghz < 1.0:
return 0.0
# Calculate specific attenuation (dB/km)
gamma = self._specific_attenuation(freq_ghz, temperature_c, humidity_percent)
# Altitude correction (less atmosphere at higher altitudes)
altitude_factor = math.exp(-altitude_m / 8500) # Scale height ~8.5km
loss = gamma * distance_km * altitude_factor
return min(loss, 20.0) # Cap for reasonable distances
def _specific_attenuation(
self,
freq_ghz: float,
temperature_c: float,
humidity_percent: float,
) -> float:
"""
Calculate specific attenuation in dB/km
Simplified ITU-R P.676 model
"""
# Water vapor density (g/m³) - simplified
# Saturation vapor pressure (hPa)
es = 6.1121 * math.exp((18.678 - temperature_c / 234.5) *
(temperature_c / (257.14 + temperature_c)))
rho = (humidity_percent / 100) * es * 216.7 / (273.15 + temperature_c)
# Oxygen absorption (dominant at 60 GHz, minor below 10 GHz)
if freq_ghz < 10:
gamma_o = 0.001 * freq_ghz ** 2 # Very small
elif freq_ghz < 57:
gamma_o = 0.001 * (freq_ghz / 10) ** 2.5
else:
# Near 60 GHz resonance
gamma_o = 15.0 # Peak absorption
# Water vapor absorption (peaks at 22 GHz and 183 GHz)
if freq_ghz < 10:
gamma_w = 0.0001 * rho * freq_ghz ** 2
elif freq_ghz < 50:
gamma_w = 0.001 * rho * (freq_ghz / 22) ** 2
else:
gamma_w = 0.01 * rho
return gamma_o + gamma_w
@staticmethod
def get_weather_description(loss_db: float) -> str:
"""Describe atmospheric conditions based on loss"""
if loss_db < 0.1:
return "clear"
elif loss_db < 0.5:
return "normal"
elif loss_db < 2.0:
return "humid"
else:
return "foggy"
atmospheric_service = AtmosphericService()
```
---
### 4. Integration into Coverage Service
**Update app/services/coverage_service.py:**
```python
# Add imports
from app.services.weather_service import weather_service
from app.services.indoor_service import indoor_service
from app.services.atmospheric_service import atmospheric_service
# Update CoverageSettings
class CoverageSettings(BaseModel):
# ... existing fields ...
# Weather
rain_rate: float = 0.0 # mm/h (0=none, 5=light, 25=heavy)
# Indoor
indoor_loss_type: str = "none" # none, light, medium, heavy
# Atmospheric
use_atmospheric: bool = False
temperature_c: float = 15.0
humidity_percent: float = 50.0
# Update _calculate_point()
async def _calculate_point(self, ...):
# ... existing calculations ...
# Rain attenuation
rain_loss = 0.0
if settings.rain_rate > 0:
rain_loss = weather_service.calculate_rain_attenuation(
site.frequency,
distance / 1000, # km
settings.rain_rate
)
# Indoor penetration
indoor_loss = 0.0
if settings.indoor_loss_type != "none":
indoor_loss = indoor_service.calculate_indoor_loss(
site.frequency,
settings.indoor_loss_type
)
# Atmospheric absorption
atmo_loss = 0.0
if settings.use_atmospheric:
atmo_loss = atmospheric_service.calculate_atmospheric_loss(
site.frequency,
distance / 1000,
settings.temperature_c,
settings.humidity_percent
)
# Final RSRP
rsrp = (site.power + site.gain - path_loss - antenna_loss
- terrain_loss - building_loss - vegetation_loss
- rain_loss - indoor_loss - atmo_loss
+ reflection_gain + water_reflection_gain)
return CoveragePoint(
# ... existing fields ...
rain_loss=rain_loss,
indoor_loss=indoor_loss,
atmospheric_loss=atmo_loss,
)
```
---
### 5. Update API Response
**Update CoveragePoint model:**
```python
class CoveragePoint(BaseModel):
lat: float
lon: float
rsrp: float
distance: float
has_los: bool
terrain_loss: float = 0.0
building_loss: float = 0.0
vegetation_loss: float = 0.0
reflection_gain: float = 0.0
rain_loss: float = 0.0 # NEW
indoor_loss: float = 0.0 # NEW
atmospheric_loss: float = 0.0 # NEW
```
---
### 6. Frontend UI Updates
**Add to CoverageSettings panel:**
```typescript
// Weather section
<div className="settings-section">
<h4>Weather Conditions</h4>
<select
value={settings.rain_rate}
onChange={(e) => updateSettings({ rain_rate: Number(e.target.value) })}
>
<option value={0}>No Rain</option>
<option value={2.5}>Drizzle</option>
<option value={5}>Light Rain</option>
<option value={12.5}>Moderate Rain</option>
<option value={25}>Heavy Rain</option>
<option value={50}>Very Heavy Rain</option>
</select>
</div>
// Indoor section
<div className="settings-section">
<h4>Indoor Coverage</h4>
<select
value={settings.indoor_loss_type}
onChange={(e) => updateSettings({ indoor_loss_type: e.target.value })}
>
<option value="none">Outdoor Only</option>
<option value="light">Light Building (wood, glass)</option>
<option value="medium">Medium Building (brick)</option>
<option value="heavy">Heavy Building (concrete)</option>
<option value="vehicle">Inside Vehicle</option>
</select>
</div>
```
---
## 📁 Files Summary
**New Files:**
```
backend/app/services/
├── weather_service.py # Rain attenuation
├── indoor_service.py # Building entry loss
└── atmospheric_service.py # Atmospheric absorption
```
**Modified Files:**
```
backend/app/
├── services/coverage_service.py # Integration
├── api/routes/coverage.py # New settings
└── models/coverage.py # New fields
frontend/src/
├── types/coverage.ts # New fields
├── services/api.ts # New settings
├── store/coverage.ts # New state
└── components/panels/CoverageSettingsPanel.tsx # UI
```
---
## 🧪 Testing
```bash
# Test rain attenuation
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":"fast",
"rain_rate":25
}
}'
# Test indoor penetration
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":"fast",
"indoor_loss_type":"medium"
}
}'
```
---
## ✅ Success Criteria
- [ ] Rain attenuation calculated correctly (0 dB at 0 mm/h, ~5 dB at 25 mm/h for 1km)
- [ ] Indoor loss applied uniformly (~15 dB for medium building)
- [ ] Atmospheric loss minimal at LTE frequencies (<0.5 dB for 10km)
- [ ] Frontend shows weather/indoor selectors
- [ ] All presets still work
- [ ] Tests pass
---
## 📝 Notes
- Rain attenuation most significant at mmWave (5G NR FR2)
- Indoor loss is uniform for coverage area (all points affected equally)
- Atmospheric loss negligible below 6 GHz for typical distances
- These are "what-if" analysis features, not real-time weather integration
---
## 🔜 Next: 2.1 — Desktop Installer
After 1.6.1:
- Electron packaging
- Offline mode with local data
- GPU acceleration for fast calculations
---
**Ready for Claude Code** 🚀