# 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

Coverage Calculation

Enables terrain elevation and line-of-sight calculations. Significantly more accurate but takes 2-3x longer.

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