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

35 KiB

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:

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:

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:

# 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:

# MongoDB вже має бути встановлений для Open5GS
# Створюємо нову базу для RFCP
mongosh
> use rfcp
> db.createCollection("projects")
> db.createCollection("coverage_cache")

Database config:

# 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:

# 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:

# 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:

# 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:

# 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:

# 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:

# 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)
# 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:

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)

# 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)

# 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)

# 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)

// 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)

// 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)

// 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)

// 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)

# 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)

# 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)

# 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)

# 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

# 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:

OpenStreetMap:

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