Files
rfcp/RFCP-Backend-Roadmap-Complete.md
2026-01-30 20:39:13 +02:00

1214 lines
35 KiB
Markdown

# RFCP Backend Development Roadmap
**Project:** RFCP Backend API for RF Coverage Planning
**Developer:** Олег
**Started:** January 30, 2025 (backend service already running!)
**Current Status:** Backend skeleton exists, needs implementation
**Priority:** Terrain & Obstacle calculations for realistic coverage
---
## 📊 Current State
**Backend Service:**
```bash
Location: /opt/rfcp/backend/
Service: rfcp-backend.service (systemd)
Port: 8888 (internal, VPN-only)
Status: Running ✅
URL: http://10.10.10.1:8888 (через WireGuard)
Stack: FastAPI + Uvicorn
```
**Access:**
- VPN-only (через WireGuard 10.10.0.0/16)
- No Caddy proxy (прямий доступ з frontend)
- No auth пока що (VPN = security)
**Current Frontend:**
- Uses localStorage for all data
- Coverage calculated in browser (Web Workers)
- No persistence between sessions (якщо очистиш cache - все пропало)
---
## 🎯 Backend Goals
### Primary Goal: **Realistic RF Coverage**
**Problem:** Current coverage calculation is theoretical (Free Space + Okumura-Hata)
**Solution:** Add real-world factors:
1. **Terrain elevation** (SRTM data)
2. **Buildings & obstacles** (OpenStreetMap data)
3. **Line-of-sight** (actual visibility)
4. **Fresnel zones** (clearance checks)
5. **Diffraction** (over obstacles)
### Secondary Goals:
1. **Data persistence** (зберігати проекти на сервері)
2. **Global project** (один проект для всіх, пізніше multi-user)
3. **Coverage cache** (не перераховувати кожен раз)
4. **Export/Import** (backup та sharing)
---
## 🏗️ Architecture
### Tech Stack
**Backend:**
- FastAPI (async Python web framework)
- MongoDB (document database)
- Uvicorn (ASGI server)
- Motor (async MongoDB driver)
**RF Calculations:**
- SRTM elevation data (NASA, 30m resolution)
- OpenStreetMap Overpass API (buildings)
- Numpy for fast array operations
- Scipy for interpolation
**Infrastructure:**
- VPS (10.10.10.1)
- Systemd service
- VPN-only access
- No auth (пока що)
### Directory Structure
```
/opt/rfcp/backend/
├── app/
│ ├── main.py # FastAPI app entry point
│ ├── api/
│ │ ├── routes/
│ │ │ ├── projects.py # Project CRUD
│ │ │ ├── sites.py # Site CRUD
│ │ │ ├── coverage.py # Coverage calculation
│ │ │ └── terrain.py # Terrain data endpoints
│ │ └── deps.py # Dependencies (DB, etc)
│ ├── core/
│ │ ├── config.py # Settings
│ │ └── database.py # MongoDB connection
│ ├── models/
│ │ ├── project.py # Pydantic models
│ │ ├── site.py
│ │ └── coverage.py
│ ├── services/
│ │ ├── rf_calculator.py # RF calculations
│ │ ├── terrain_service.py # SRTM data handling
│ │ ├── osm_service.py # OpenStreetMap integration
│ │ └── cache_service.py # Coverage caching
│ └── utils/
│ ├── geo.py # Geographic utilities
│ └── logger.py # Logging setup
├── data/
│ ├── srtm/ # Cached SRTM tiles
│ └── osm_cache/ # Cached OSM data
├── tests/
├── venv/
└── requirements.txt
```
---
## 📋 Development Phases
## Phase 1: Foundation (Week 1) - 8-12 hours
**Goal:** Basic API structure + MongoDB integration
### Task 1.1: Project Setup (2-3 hours)
**Install dependencies:**
```bash
cd /opt/rfcp/backend
source venv/bin/activate
pip install fastapi uvicorn motor pydantic-settings pymongo
pip install numpy scipy requests pillow
```
**Create base structure:**
```python
# app/main.py
from fastapi import FastAPI
from app.api.routes import projects, sites, coverage
app = FastAPI(title="RFCP Backend API", version="1.0.0")
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"])
@app.get("/")
async def root():
return {"message": "RFCP Backend API", "status": "running"}
@app.get("/health")
async def health():
return {"status": "ok"}
```
### Task 1.2: MongoDB Connection (2 hours)
**Setup MongoDB:**
```bash
# MongoDB вже має бути встановлений для Open5GS
# Створюємо нову базу для RFCP
mongosh
> use rfcp
> db.createCollection("projects")
> db.createCollection("coverage_cache")
```
**Database config:**
```python
# app/core/database.py
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.rfcp
async def connect_to_mongo():
db.client = AsyncIOMotorClient(settings.MONGODB_URL)
async def close_mongo_connection():
db.client.close()
```
### Task 1.3: Data Models (2 hours)
**Pydantic models:**
```python
# app/models/project.py
from pydantic import BaseModel, Field
from typing import List, Optional
from datetime import datetime
class Sector(BaseModel):
id: str
name: str # Alpha, Beta, Gamma
power: float # dBm
gain: float # dBi
height: float # meters
frequency: float # MHz
azimuth: float # degrees
beamwidth: float # degrees
antenna_type: str # "omnidirectional" or "directional"
class Site(BaseModel):
id: str
name: str
lat: float
lon: float
sectors: List[Sector]
class CoverageSettings(BaseModel):
radius: float = 10000 # meters
resolution: float = 200 # meters
min_signal: float = -105 # dBm
use_terrain: bool = False # Enable terrain calculations
use_buildings: bool = False # Enable building obstacles
class Project(BaseModel):
id: Optional[str] = None
name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
sites: List[Site] = []
settings: CoverageSettings = CoverageSettings()
```
### Task 1.4: Basic CRUD Endpoints (3-4 hours)
**Project endpoints:**
```python
# app/api/routes/projects.py
from fastapi import APIRouter, Depends
from app.models.project import Project
from app.core.database import get_database
router = APIRouter()
@router.get("/current")
async def get_current_project(db = Depends(get_database)):
"""Get the global project (single project for all users)"""
project = await db.projects.find_one({"name": "global"})
if not project:
# Create default project if doesn't exist
project = Project(name="global", sites=[], settings=CoverageSettings())
await db.projects.insert_one(project.dict())
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()
await db.projects.update_one(
{"name": "global"},
{"$set": project.dict()},
upsert=True
)
return project
@router.get("/current/sites")
async def get_sites(db = Depends(get_database)):
"""Get all sites from global project"""
project = await db.projects.find_one({"name": "global"})
return project.get("sites", []) if project else []
@router.put("/current/sites")
async def update_sites(sites: List[Site], db = Depends(get_database)):
"""Update all sites in global project"""
await db.projects.update_one(
{"name": "global"},
{"$set": {"sites": [s.dict() 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"})
return project.get("settings", CoverageSettings().dict()) if project else CoverageSettings().dict()
@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.dict(), "updated_at": datetime.utcnow()}},
upsert=True
)
return settings
```
**Testing:**
```bash
# Test health endpoint
curl http://10.10.10.1:8888/health
# Test get current project
curl http://10.10.10.1:8888/api/projects/current
# Test update sites
curl -X PUT http://10.10.10.1:8888/api/projects/current/sites \
-H "Content-Type: application/json" \
-d '[{"id":"1","name":"Test","lat":48.5,"lon":35.0,"sectors":[]}]'
```
---
## Phase 2: Terrain Integration (Week 2-3) - 16-24 hours ⭐ PRIORITY
**Goal:** Realistic coverage with terrain elevation and obstacles
### Task 2.1: SRTM Data Service (4-6 hours)
**What is SRTM?**
- NASA elevation data (висота місцевості)
- 30m resolution (Ukraine fully covered)
- HGT files (~25MB per tile)
- Free download from USGS
**Implementation:**
```python
# app/services/terrain_service.py
import requests
import numpy as np
from pathlib import Path
from PIL import Image
class TerrainService:
def __init__(self, cache_dir="/opt/rfcp/backend/data/srtm"):
self.cache_dir = Path(cache_dir)
self.cache_dir.mkdir(exist_ok=True, parents=True)
def get_tile_name(self, lat: float, lon: float) -> str:
"""Convert lat/lon to SRTM tile name (e.g., N48E035)"""
lat_letter = 'N' if lat >= 0 else 'S'
lon_letter = 'E' if lon >= 0 else 'W'
return f"{lat_letter}{abs(int(lat)):02d}{lon_letter}{abs(int(lon)):03d}"
async def get_elevation(self, lat: float, lon: float) -> float:
"""Get elevation at specific coordinate"""
tile_name = self.get_tile_name(lat, lon)
tile_path = self.cache_dir / f"{tile_name}.hgt"
if not tile_path.exists():
await self.download_tile(tile_name)
# Read HGT file (3601x3601 array of 16-bit integers)
with open(tile_path, 'rb') as f:
data = np.fromfile(f, dtype='>i2').reshape(3601, 3601)
# Calculate position in tile
lat_frac = lat - int(lat)
lon_frac = lon - int(lon)
row = int((1 - lat_frac) * 3600)
col = int(lon_frac * 3600)
elevation = data[row, col]
return float(elevation) if elevation != -32768 else 0.0 # -32768 = no data
async def download_tile(self, tile_name: str):
"""Download SRTM tile from USGS or mirror"""
# USGS requires login, використовуємо mirror
url = f"https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF/{tile_name}.zip"
# Download and extract...
# (Implementation omitted for brevity)
async def get_elevation_profile(self, lat1: float, lon1: float,
lat2: float, lon2: float,
num_points: int = 100) -> List[float]:
"""Get elevation profile between two points"""
lats = np.linspace(lat1, lat2, num_points)
lons = np.linspace(lon1, lon2, num_points)
elevations = []
for lat, lon in zip(lats, lons):
elev = await self.get_elevation(lat, lon)
elevations.append(elev)
return elevations
```
**API endpoint:**
```python
# app/api/routes/terrain.py
from fastapi import APIRouter
from app.services.terrain_service import TerrainService
router = APIRouter()
terrain = TerrainService()
@router.get("/elevation")
async def get_elevation(lat: float, lon: float):
"""Get elevation at specific point"""
elevation = await terrain.get_elevation(lat, lon)
return {"lat": lat, "lon": lon, "elevation": elevation}
@router.get("/profile")
async def get_elevation_profile(lat1: float, lon1: float, lat2: float, lon2: float):
"""Get elevation profile between two points"""
profile = await terrain.get_elevation_profile(lat1, lon1, lat2, lon2)
return {"profile": profile}
```
### Task 2.2: Line-of-Sight Calculation (3-4 hours)
**What is LOS?**
- Чи може сигнал дійти від антени до точки
- Чи закривають гори/будинки
- Враховує висоту антени та кривизну Землі
**Implementation:**
```python
# app/services/rf_calculator.py
import numpy as np
from typing import Tuple
class RFCalculator:
EARTH_RADIUS = 6371000 # meters
async def check_line_of_sight(self,
site_lat: float, site_lon: float, site_height: float,
point_lat: float, point_lon: float, point_height: float = 1.5,
terrain_service = None) -> Tuple[bool, float]:
"""
Check if there's line-of-sight between site and point
Returns:
(has_los: bool, clearance: float) - clearance in meters (negative = blocked)
"""
if not terrain_service:
return True, 0.0 # No terrain data, assume clear
# Get elevation profile
profile = await terrain_service.get_elevation_profile(
site_lat, site_lon, point_lat, point_lon, num_points=50
)
# Get site and point elevations
site_elevation = await terrain_service.get_elevation(site_lat, site_lon)
point_elevation = await terrain_service.get_elevation(point_lat, point_lon)
# Calculate total heights
site_total = site_elevation + site_height
point_total = point_elevation + point_height
# Calculate distance
distance = self.haversine_distance(site_lat, site_lon, point_lat, point_lon)
# Check each point along the path
min_clearance = float('inf')
for i, terrain_elev in enumerate(profile):
# Distance from site to this point
d = (i / len(profile)) * distance
# Height of line-of-sight at this point (linear interpolation)
los_height = site_total + (point_total - site_total) * (d / distance)
# Add Earth curvature correction
curvature = (d * (distance - d)) / (2 * self.EARTH_RADIUS)
los_height -= curvature
# Clearance at this point
clearance = los_height - terrain_elev
if clearance < min_clearance:
min_clearance = clearance
has_los = min_clearance > 0
return has_los, min_clearance
def haversine_distance(self, lat1, lon1, lat2, lon2):
"""Calculate distance between two points in 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 self.EARTH_RADIUS * c
```
### Task 2.3: Fresnel Zone Clearance (3-4 hours)
**What is Fresnel Zone?**
- Еліпсоїдна зона навколо прямої лінії між антенами
- Для хорошого сигналу потрібно 60-80% clearance
- Важливо для LTE (2.6 GHz)
```python
# app/services/rf_calculator.py (continued)
def calculate_fresnel_radius(self, distance_total: float, distance_to_point: float,
frequency_mhz: float) -> float:
"""
Calculate 1st Fresnel zone radius at specific point
Args:
distance_total: Total distance between antennas (meters)
distance_to_point: Distance from transmitter to this point (meters)
frequency_mhz: Frequency in MHz
Returns:
Fresnel radius in meters
"""
# Wavelength (λ = c / f)
wavelength = 300 / frequency_mhz # meters (c = 300,000 km/s)
d1 = distance_to_point
d2 = distance_total - distance_to_point
# 1st Fresnel zone radius
radius = np.sqrt((wavelength * d1 * d2) / distance_total)
return radius
async def check_fresnel_clearance(self,
site_lat: float, site_lon: float, site_height: float,
point_lat: float, point_lon: float, point_height: float,
frequency_mhz: float,
terrain_service = None) -> float:
"""
Check Fresnel zone clearance percentage
Returns:
Clearance percentage (100% = fully clear, 60% = minimum acceptable)
"""
if not terrain_service:
return 100.0
distance_total = self.haversine_distance(site_lat, site_lon, point_lat, point_lon)
profile = await terrain_service.get_elevation_profile(
site_lat, site_lon, point_lat, point_lon, num_points=50
)
site_elevation = await terrain_service.get_elevation(site_lat, site_lon)
point_elevation = await terrain_service.get_elevation(point_lat, point_lon)
site_total = site_elevation + site_height
point_total = point_elevation + point_height
worst_clearance_pct = 100.0
for i, terrain_elev in enumerate(profile):
d = (i / len(profile)) * distance_total
# LOS height at this point
los_height = site_total + (point_total - site_total) * (d / distance_total)
# Fresnel radius at this point
fresnel_radius = self.calculate_fresnel_radius(distance_total, d, frequency_mhz)
# Required clearance (60% of 1st Fresnel zone)
required_clearance = los_height + 0.6 * fresnel_radius
# Actual clearance
actual_clearance = los_height - terrain_elev
# Clearance percentage
clearance_pct = (actual_clearance / (0.6 * fresnel_radius)) * 100
if clearance_pct < worst_clearance_pct:
worst_clearance_pct = clearance_pct
return worst_clearance_pct
```
### Task 2.4: Path Loss with Terrain (4-6 hours)
**Enhanced path loss calculation:**
```python
async def calculate_path_loss_with_terrain(self,
site_lat: float, site_lon: float, site_height: float,
point_lat: float, point_lon: float, point_height: float,
frequency_mhz: float,
power_dbm: float,
gain_dbi: float,
terrain_service = None) -> float:
"""
Calculate RSRP with terrain considerations
Returns:
RSRP in dBm
"""
# Base path loss (Free Space or Okumura-Hata)
distance = self.haversine_distance(site_lat, site_lon, point_lat, point_lon)
base_path_loss = self.calculate_okumura_hata(distance, frequency_mhz, site_height, point_height)
# Terrain factors
terrain_loss = 0.0
if terrain_service:
# Check line-of-sight
has_los, clearance = await self.check_line_of_sight(
site_lat, site_lon, site_height,
point_lat, point_lon, point_height,
terrain_service
)
if not has_los:
# No LOS - add diffraction loss
# Simple knife-edge diffraction model
terrain_loss += 20 # Basic obstruction loss
if clearance < -10: # Deep obstruction
terrain_loss += abs(clearance) * 0.5
# Check Fresnel clearance
fresnel_pct = await self.check_fresnel_clearance(
site_lat, site_lon, site_height,
point_lat, point_lon, point_height,
frequency_mhz,
terrain_service
)
if fresnel_pct < 60:
# Partial Fresnel zone obstruction
loss_factor = (60 - fresnel_pct) / 60 # 0 to 1
terrain_loss += loss_factor * 10 # Up to 10 dB extra loss
# Calculate final RSRP
rsrp = power_dbm + gain_dbi - base_path_loss - terrain_loss
return rsrp
```
### Task 2.5: Coverage Calculation Endpoint (2-3 hours)
```python
# app/api/routes/coverage.py
from fastapi import APIRouter, Depends, BackgroundTasks
from app.services.rf_calculator import RFCalculator
from app.services.terrain_service import TerrainService
from app.core.database import get_database
router = APIRouter()
rf_calc = RFCalculator()
terrain = TerrainService()
@router.post("/calculate")
async def calculate_coverage(background_tasks: BackgroundTasks,
use_terrain: bool = False,
db = Depends(get_database)):
"""
Calculate coverage for current project
Args:
use_terrain: Enable terrain-based calculations (slower but realistic)
"""
# Get project data
project = await db.projects.find_one({"name": "global"})
sites = project["sites"]
settings = project["settings"]
# Generate grid points
# (Based on settings.radius and settings.resolution)
# Calculate coverage for each point
coverage_points = []
for point in grid_points:
best_rsrp = -150 # Very weak
best_site_id = None
for site in sites:
for sector in site["sectors"]:
if use_terrain:
rsrp = await rf_calc.calculate_path_loss_with_terrain(
site["lat"], site["lon"], sector["height"],
point["lat"], point["lon"], 1.5, # 1.5m receiver height
sector["frequency"],
sector["power"],
sector["gain"],
terrain
)
else:
# Simple calculation (current frontend method)
rsrp = rf_calc.calculate_simple_rsrp(...)
if rsrp > best_rsrp:
best_rsrp = rsrp
best_site_id = site["id"]
if best_rsrp > settings["min_signal"]:
coverage_points.append({
"lat": point["lat"],
"lon": point["lon"],
"rsrp": best_rsrp,
"site_id": best_site_id
})
# Cache results
await db.coverage_cache.update_one(
{"project_name": "global"},
{"$set": {
"calculated_at": datetime.utcnow(),
"use_terrain": use_terrain,
"coverage_points": coverage_points
}},
upsert=True
)
return {
"points": len(coverage_points),
"use_terrain": use_terrain,
"calculation_time": "..."
}
@router.get("/cache")
async def get_cached_coverage(db = Depends(get_database)):
"""Get cached coverage results"""
cache = await db.coverage_cache.find_one({"project_name": "global"})
if not cache:
return {"cached": False}
return {
"cached": True,
"calculated_at": cache["calculated_at"],
"use_terrain": cache["use_terrain"],
"coverage_points": cache["coverage_points"]
}
```
---
## Phase 3: Building Obstacles (Week 4) - 12-16 hours
**Goal:** Add building obstacles from OpenStreetMap
### Task 3.1: OSM Data Service (6-8 hours)
```python
# app/services/osm_service.py
import requests
from typing import List, Dict
class OSMService:
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
async def get_buildings_in_area(self, min_lat: float, min_lon: float,
max_lat: float, max_lon: float) -> List[Dict]:
"""
Get all buildings in bounding box from OpenStreetMap
Returns:
List of buildings with coordinates and height
"""
# Overpass QL query
query = f"""
[out:json];
(
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;
"""
response = requests.post(
self.OVERPASS_URL,
data={"data": query}
)
data = response.json()
buildings = []
for element in data["elements"]:
if element["type"] == "way":
# Extract building height if available
height = element.get("tags", {}).get("height", None)
if height:
height = float(height.replace("m", ""))
else:
# Estimate height from building:levels
levels = element.get("tags", {}).get("building:levels", 3)
height = float(levels) * 3.5 # 3.5m per floor
# Get building polygon
nodes = element.get("nodes", [])
# ... extract coordinates
buildings.append({
"id": element["id"],
"height": height,
"polygon": [...], # List of (lat, lon) coordinates
})
return buildings
def is_point_in_building(self, lat: float, lon: float, building: Dict) -> bool:
"""Check if point is inside building polygon"""
# Ray casting algorithm
# ...
pass
```
### Task 3.2: Building Obstacles in LOS (6-8 hours)
```python
# Integrate buildings into LOS calculation
async def check_line_of_sight_with_buildings(self, ...):
"""Enhanced LOS check with buildings"""
# Get buildings in area
buildings = await osm_service.get_buildings_in_area(...)
# Check each point along path
for point in profile:
# Check if point intersects with any building
for building in buildings:
if osm_service.is_point_in_building(point.lat, point.lon, building):
# Line passes through building
if point_height < building["height"]:
# Signal is blocked
return False, -building["height"]
return True, min_clearance
```
---
## Phase 4: Frontend Integration (Week 5) - 8-12 hours
**Goal:** Replace localStorage with backend API calls
### Task 4.1: API Client (3-4 hours)
```typescript
// src/api/client.ts
const API_BASE = 'http://10.10.10.1:8888/api';
export class RFCPApiClient {
// Projects
async getCurrentProject() {
const res = await fetch(`${API_BASE}/projects/current`);
return res.json();
}
async updateProject(project: Project) {
const res = await fetch(`${API_BASE}/projects/current`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(project)
});
return res.json();
}
// Sites
async getSites() {
const res = await fetch(`${API_BASE}/projects/current/sites`);
return res.json();
}
async updateSites(sites: Site[]) {
const res = await fetch(`${API_BASE}/projects/current/sites`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sites)
});
return res.json();
}
// Settings
async getSettings() {
const res = await fetch(`${API_BASE}/projects/current/settings`);
return res.json();
}
async updateSettings(settings: CoverageSettings) {
const res = await fetch(`${API_BASE}/projects/current/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings)
});
return res.json();
}
// Coverage
async calculateCoverage(useTerrain: boolean = false) {
const res = await fetch(`${API_BASE}/coverage/calculate?use_terrain=${useTerrain}`, {
method: 'POST'
});
return res.json();
}
async getCachedCoverage() {
const res = await fetch(`${API_BASE}/coverage/cache`);
return res.json();
}
// Terrain
async getElevation(lat: float, lon: float) {
const res = await fetch(`${API_BASE}/terrain/elevation?lat=${lat}&lon=${lon}`);
return res.json();
}
}
export const api = new RFCPApiClient();
```
### Task 4.2: Update Zustand Stores (3-4 hours)
```typescript
// src/store/sites.ts (updated)
import { api } from '@/api/client';
export const useSitesStore = create((set, get) => ({
sites: [],
isLoading: false,
error: null,
// Load from backend on app start
loadSites: async () => {
set({ isLoading: true, error: null });
try {
const sites = await api.getSites();
set({ sites, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
// Save to backend
saveSites: async () => {
const { sites } = get();
try {
await api.updateSites(sites);
} catch (error) {
console.error('Failed to save sites:', error);
}
},
addSite: (site) => {
set((state) => ({ sites: [...state.sites, site] }));
get().saveSites(); // Auto-save to backend
},
updateSite: (id, updates) => {
set((state) => ({
sites: state.sites.map(s => s.id === id ? { ...s, ...updates } : s)
}));
get().saveSites(); // Auto-save
},
deleteSite: (id) => {
set((state) => ({ sites: state.sites.filter(s => s.id !== id) }));
get().saveSites(); // Auto-save
}
}));
```
### Task 4.3: Add Terrain Toggle (2 hours)
```typescript
// src/components/panels/SettingsPanel.tsx
<div className="setting-group">
<h3>Coverage Calculation</h3>
<label>
<input
type="checkbox"
checked={useTerrainCalculation}
onChange={(e) => setUseTerrainCalculation(e.target.checked)}
/>
Use Terrain Data (realistic, slower)
</label>
<p className="hint">
Enables terrain elevation and line-of-sight calculations.
Significantly more accurate but takes 2-3x longer.
</p>
<button
onClick={() => calculateCoverage(useTerrainCalculation)}
disabled={isCalculating}
>
{isCalculating ? 'Calculating...' : 'Calculate Coverage'}
</button>
</div>
```
### Task 4.4: Coverage Calculation Integration (2-3 hours)
```typescript
// src/store/coverage.ts
export const useCoverageStore = create((set) => ({
coveragePoints: [],
isCalculating: false,
useTerrain: false,
calculateCoverage: async (useTerrain: boolean) => {
set({ isCalculating: true, useTerrain });
try {
// Trigger backend calculation
await api.calculateCoverage(useTerrain);
// Poll for results (or use WebSocket)
const pollInterval = setInterval(async () => {
const cache = await api.getCachedCoverage();
if (cache.cached) {
clearInterval(pollInterval);
set({
coveragePoints: cache.coverage_points,
isCalculating: false
});
}
}, 2000); // Poll every 2 seconds
} catch (error) {
console.error('Coverage calculation failed:', error);
set({ isCalculating: false });
}
}
}));
```
---
## Phase 5: Performance & Polish (Week 6) - 8-12 hours
### Task 5.1: Coverage Caching Strategy (3-4 hours)
```python
# Smart cache invalidation
async def should_recalculate_coverage(project, cached_coverage):
"""Check if coverage needs recalculation"""
# Check if sites changed
if project["updated_at"] > cached_coverage["calculated_at"]:
return True
# Check if settings changed
if project["settings"] != cached_coverage["settings"]:
return True
# Check if terrain option changed
if cached_coverage["use_terrain"] != requested_use_terrain:
return True
return False
```
### Task 5.2: Background Tasks (2-3 hours)
```python
# Use FastAPI BackgroundTasks for long calculations
from fastapi import BackgroundTasks
@router.post("/calculate")
async def calculate_coverage(background_tasks: BackgroundTasks, ...):
background_tasks.add_task(run_coverage_calculation, project_id, settings)
return {"status": "calculation_started"}
async def run_coverage_calculation(project_id, settings):
# Long-running calculation
# Update cache when done
pass
```
### Task 5.3: API Documentation (2 hours)
```python
# FastAPI auto-generates Swagger docs
# Add descriptions and examples
@router.get("/current",
summary="Get global project",
description="Returns the shared project with all sites and settings",
response_description="Project object with sites array"
)
async def get_current_project(...):
pass
```
**Access docs at:** http://10.10.10.1:8888/docs
### Task 5.4: Error Handling (2-3 hours)
```python
# Add proper error handling
from fastapi import HTTPException
@router.get("/current")
async def get_current_project(db = Depends(get_database)):
try:
project = await db.projects.find_one({"name": "global"})
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
except Exception as e:
logger.error(f"Failed to get project: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
```
---
## 📊 Phase Summary
| Phase | Goal | Time | Priority |
|-------|------|------|----------|
| 1 | Foundation (API + DB) | 8-12h | P0 |
| 2 | Terrain Integration | 16-24h | P0 ⭐ |
| 3 | Building Obstacles | 12-16h | P1 |
| 4 | Frontend Integration | 8-12h | P0 |
| 5 | Performance & Polish | 8-12h | P1 |
| **Total** | **Full Backend MVP** | **52-76h** | **~2 months** |
---
## 🎯 Success Criteria
### Phase 1 Complete:
- ✅ Backend API responds on http://10.10.10.1:8888
- ✅ MongoDB stores projects
- ✅ Can save/load sites via API
- ✅ Swagger docs accessible
### Phase 2 Complete:
- ✅ SRTM elevation data downloads
- ✅ Line-of-sight calculation works
- ✅ Fresnel zone clearance calculated
- ✅ Coverage shows realistic terrain effects
### Phase 3 Complete:
- ✅ Buildings downloaded from OSM
- ✅ Building obstacles affect coverage
- ✅ Urban areas show reduced coverage
### Phase 4 Complete:
- ✅ Frontend uses backend API
- ✅ No more localStorage (except cache)
- ✅ Terrain toggle works
- ✅ Coverage persists between sessions
### Phase 5 Complete:
- ✅ Coverage calculation <30s with terrain
- ✅ Cache prevents redundant calculations
- ✅ API docs complete
- ✅ Error handling robust
---
## 🚀 Quick Start Commands
```bash
# Activate venv
cd /opt/rfcp/backend
source venv/bin/activate
# Install deps
pip install -r requirements.txt
# Run dev server
uvicorn app.main:app --host 0.0.0.0 --port 8888 --reload
# Check service status
systemctl status rfcp-backend.service
# View logs
journalctl -u rfcp-backend.service -f
# Test API
curl http://10.10.10.1:8888/health
curl http://10.10.10.1:8888/docs # Swagger UI
```
---
## 📚 References
**SRTM Data:**
- NASA SRTM: https://www2.jpl.nasa.gov/srtm/
- CGIAR Mirror: https://srtm.csi.cgiar.org/
- Coverage: Ukraine fully covered at 30m resolution
**OpenStreetMap:**
- Overpass API: https://overpass-api.de/
- Building tags: https://wiki.openstreetmap.org/wiki/Key:building
**RF Propagation:**
- ITU-R P.526: Diffraction
- ITU-R P.530: Line-of-sight
- Okumura-Hata model
- Fresnel zones
---
## 🎬 Next Steps
1. **Immediate:** Start Phase 1 (Foundation)
2. **Priority:** Phase 2 (Terrain) - це найважливіше для реалістичного покриття
3. **Then:** Phase 4 (Frontend integration)
4. **Finally:** Phase 3 (Buildings) + Phase 5 (Polish)
---
**Status:** 📋 ROADMAP COMPLETE
**Ready for:** Implementation
**Estimated Timeline:** 2 months (52-76 hours)
**Priority Focus:** Terrain-based coverage calculations