1214 lines
35 KiB
Markdown
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
|
|
|