@mytec: initial commit before dt
This commit is contained in:
1213
docs/devlog/back/RFCP-Backend-Roadmap-Complete.md
Normal file
1213
docs/devlog/back/RFCP-Backend-Roadmap-Complete.md
Normal file
File diff suppressed because it is too large
Load Diff
366
docs/devlog/back/RFCP-Iteration-1.1-Backend-Foundation.md
Normal file
366
docs/devlog/back/RFCP-Iteration-1.1-Backend-Foundation.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# RFCP Backend - Iteration 1.1: Foundation
|
||||
|
||||
**Date:** January 30, 2025
|
||||
**Type:** Backend Development
|
||||
**Estimated:** 2-3 hours
|
||||
**Location:** `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Set up basic FastAPI structure with MongoDB connection and CRUD endpoints for projects/sites.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-reading (IMPORTANT)
|
||||
|
||||
Before starting, read these documents in project knowledge:
|
||||
|
||||
1. `RFCP-Backend-Roadmap-Complete.md` — full backend architecture
|
||||
2. `RFCP-ARCHITECTURE.md` — system overview
|
||||
3. `SESSION-2025-01-30-Complete.md` — context
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
```bash
|
||||
# Service already running
|
||||
systemctl status rfcp-backend.service # ✅ active
|
||||
|
||||
# Current structure
|
||||
/opt/rfcp/backend/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ └── main.py # Basic FastAPI app
|
||||
├── venv/ # Python 3.12, FastAPI installed
|
||||
├── Dockerfile
|
||||
└── requirements.txt
|
||||
|
||||
# MongoDB available (Open5GS instance)
|
||||
# Will use separate database: "rfcp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
cd /opt/rfcp/backend
|
||||
source venv/bin/activate
|
||||
|
||||
pip install motor pydantic-settings pymongo
|
||||
pip install numpy scipy requests
|
||||
```
|
||||
|
||||
Update `requirements.txt` with new deps.
|
||||
|
||||
---
|
||||
|
||||
### 2. Create Directory Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── __init__.py
|
||||
├── main.py
|
||||
├── api/
|
||||
│ ├── __init__.py
|
||||
│ ├── deps.py
|
||||
│ └── routes/
|
||||
│ ├── __init__.py
|
||||
│ ├── projects.py
|
||||
│ └── health.py
|
||||
├── core/
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py
|
||||
│ └── database.py
|
||||
└── models/
|
||||
├── __init__.py
|
||||
├── project.py
|
||||
└── site.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Implement Core Components
|
||||
|
||||
**app/core/config.py:**
|
||||
```python
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
MONGODB_URL: str = "mongodb://localhost:27017"
|
||||
DATABASE_NAME: str = "rfcp"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
settings = Settings()
|
||||
```
|
||||
|
||||
**app/core/database.py:**
|
||||
```python
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
from app.core.config import settings
|
||||
|
||||
class Database:
|
||||
client: AsyncIOMotorClient = None
|
||||
|
||||
db = Database()
|
||||
|
||||
async def get_database():
|
||||
return db.client[settings.DATABASE_NAME]
|
||||
|
||||
async def connect_to_mongo():
|
||||
db.client = AsyncIOMotorClient(settings.MONGODB_URL)
|
||||
|
||||
async def close_mongo_connection():
|
||||
if db.client:
|
||||
db.client.close()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Implement Models
|
||||
|
||||
**app/models/site.py:**
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
class Sector(BaseModel):
|
||||
id: str
|
||||
name: str # Alpha, Beta, Gamma...
|
||||
power: float = 43 # dBm
|
||||
gain: float = 8 # dBi
|
||||
height: float = 30 # meters
|
||||
frequency: float = 1800 # MHz
|
||||
azimuth: float = 0 # degrees
|
||||
beamwidth: float = 65 # degrees
|
||||
tilt: float = 0 # degrees (electrical downtilt)
|
||||
antenna_type: str = "directional"
|
||||
|
||||
class Site(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
lat: float
|
||||
lon: float
|
||||
sectors: List[Sector] = []
|
||||
```
|
||||
|
||||
**app/models/project.py:**
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from app.models.site import Site
|
||||
|
||||
class CoverageSettings(BaseModel):
|
||||
radius: float = 10000 # meters
|
||||
resolution: float = 200 # meters
|
||||
min_signal: float = -105 # dBm
|
||||
max_signal: float = -65 # dBm
|
||||
|
||||
class Project(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: str = "global"
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
sites: List[Site] = []
|
||||
settings: CoverageSettings = Field(default_factory=CoverageSettings)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Implement Routes
|
||||
|
||||
**app/api/routes/health.py:**
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.core.database import get_database
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "rfcp-backend"}
|
||||
|
||||
@router.get("/db")
|
||||
async def db_check(db = Depends(get_database)):
|
||||
try:
|
||||
await db.command("ping")
|
||||
return {"status": "ok", "database": "connected"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "database": str(e)}
|
||||
```
|
||||
|
||||
**app/api/routes/projects.py:**
|
||||
```python
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
from app.core.database import get_database
|
||||
from app.models.project import Project, CoverageSettings
|
||||
from app.models.site import Site
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/current")
|
||||
async def get_current_project(db = Depends(get_database)):
|
||||
"""Get the global project"""
|
||||
project = await db.projects.find_one({"name": "global"})
|
||||
if not project:
|
||||
# Create default if doesn't exist
|
||||
default = Project(name="global")
|
||||
await db.projects.insert_one(default.model_dump())
|
||||
return default
|
||||
project.pop("_id", None)
|
||||
return project
|
||||
|
||||
@router.put("/current")
|
||||
async def update_current_project(project: Project, db = Depends(get_database)):
|
||||
"""Update the global project"""
|
||||
project.updated_at = datetime.utcnow()
|
||||
data = project.model_dump()
|
||||
await db.projects.update_one(
|
||||
{"name": "global"},
|
||||
{"$set": data},
|
||||
upsert=True
|
||||
)
|
||||
return project
|
||||
|
||||
@router.get("/current/sites", response_model=List[Site])
|
||||
async def get_sites(db = Depends(get_database)):
|
||||
"""Get all sites"""
|
||||
project = await db.projects.find_one({"name": "global"})
|
||||
if not project:
|
||||
return []
|
||||
return project.get("sites", [])
|
||||
|
||||
@router.put("/current/sites")
|
||||
async def update_sites(sites: List[Site], db = Depends(get_database)):
|
||||
"""Update all sites"""
|
||||
await db.projects.update_one(
|
||||
{"name": "global"},
|
||||
{
|
||||
"$set": {
|
||||
"sites": [s.model_dump() for s in sites],
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
},
|
||||
upsert=True
|
||||
)
|
||||
return {"updated": len(sites)}
|
||||
|
||||
@router.get("/current/settings")
|
||||
async def get_settings(db = Depends(get_database)):
|
||||
"""Get coverage settings"""
|
||||
project = await db.projects.find_one({"name": "global"})
|
||||
if not project:
|
||||
return CoverageSettings()
|
||||
return project.get("settings", CoverageSettings().model_dump())
|
||||
|
||||
@router.put("/current/settings")
|
||||
async def update_settings(settings: CoverageSettings, db = Depends(get_database)):
|
||||
"""Update coverage settings"""
|
||||
await db.projects.update_one(
|
||||
{"name": "global"},
|
||||
{
|
||||
"$set": {
|
||||
"settings": settings.model_dump(),
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
},
|
||||
upsert=True
|
||||
)
|
||||
return settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Update main.py
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.database import connect_to_mongo, close_mongo_connection
|
||||
from app.api.routes import health, projects
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await connect_to_mongo()
|
||||
yield
|
||||
await close_mongo_connection()
|
||||
|
||||
app = FastAPI(
|
||||
title="RFCP Backend API",
|
||||
description="RF Coverage Planning Backend",
|
||||
version="1.1.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# CORS for frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://rfcp.eliah.one", "http://localhost:5173"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Routes
|
||||
app.include_router(health.router, prefix="/api/health", tags=["health"])
|
||||
app.include_router(projects.router, prefix="/api/projects", tags=["projects"])
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "RFCP Backend API", "version": "1.1.0"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Restart Service & Test
|
||||
|
||||
```bash
|
||||
# Restart
|
||||
sudo systemctl restart rfcp-backend.service
|
||||
|
||||
# Check logs
|
||||
journalctl -u rfcp-backend.service -f
|
||||
|
||||
# Test endpoints
|
||||
curl http://10.10.10.1:8888/
|
||||
curl http://10.10.10.1:8888/api/health/
|
||||
curl http://10.10.10.1:8888/api/health/db
|
||||
curl http://10.10.10.1:8888/api/projects/current
|
||||
curl http://10.10.10.1:8888/docs # Swagger UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Service running without errors
|
||||
- [ ] `/api/health/` returns `{"status": "ok"}`
|
||||
- [ ] `/api/health/db` returns `{"database": "connected"}`
|
||||
- [ ] `/api/projects/current` returns project (creates if empty)
|
||||
- [ ] PUT `/api/projects/current/sites` saves sites to MongoDB
|
||||
- [ ] Swagger docs accessible at `/docs`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- MongoDB database: `rfcp` (separate from Open5GS)
|
||||
- No auth for now (VPN = security layer)
|
||||
- CORS configured for frontend
|
||||
- Pydantic v2 syntax (`model_dump()` not `dict()`)
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
308
docs/devlog/back/RFCP-Iteration-1.1.1-UX-Safety-Undo-Redo.md
Normal file
308
docs/devlog/back/RFCP-Iteration-1.1.1-UX-Safety-Undo-Redo.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# RFCP Frontend - Iteration 1.1.1: UX Safety & Undo/Redo
|
||||
|
||||
**Date:** January 30, 2025
|
||||
**Type:** Frontend Enhancement
|
||||
**Estimated:** 2-3 hours
|
||||
**Location:** `/opt/rfcp/frontend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Add safety confirmations for destructive actions and implement full Undo/Redo system.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-reading
|
||||
|
||||
1. Review current state management in `src/store/` (Zustand)
|
||||
2. Check existing toast implementation for delete actions
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
- Toast exists for delete actions (basic)
|
||||
- No unsaved changes detection
|
||||
- No confirmation dialogs
|
||||
- No undo/redo system
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### 1. Unsaved Changes Detection
|
||||
|
||||
**Add dirty state tracking to store:**
|
||||
|
||||
```typescript
|
||||
// src/store/projectStore.ts (or similar)
|
||||
|
||||
interface ProjectState {
|
||||
// ... existing
|
||||
isDirty: boolean;
|
||||
lastSavedState: string | null; // JSON snapshot
|
||||
|
||||
markDirty: () => void;
|
||||
markClean: () => void;
|
||||
checkDirty: () => boolean;
|
||||
}
|
||||
|
||||
// On any change to sites/settings:
|
||||
markDirty()
|
||||
|
||||
// On save:
|
||||
markClean()
|
||||
lastSavedState = JSON.stringify(currentState)
|
||||
```
|
||||
|
||||
**Browser beforeunload warning:**
|
||||
|
||||
```typescript
|
||||
// src/App.tsx or dedicated hook
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (store.isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = ''; // Required for Chrome
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Confirmation Dialogs
|
||||
|
||||
**Create reusable ConfirmDialog component:**
|
||||
|
||||
```typescript
|
||||
// src/components/ui/ConfirmDialog.tsx
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string; // default: "Confirm"
|
||||
cancelText?: string; // default: "Cancel"
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Styling:
|
||||
// - danger: red confirm button (for delete)
|
||||
// - warning: yellow/orange (for discard changes)
|
||||
// - info: blue (for info confirmations)
|
||||
```
|
||||
|
||||
**Apply to actions:**
|
||||
|
||||
| Action | Dialog |
|
||||
|--------|--------|
|
||||
| Load project (when dirty) | "Є незбережені зміни. Завантажити інший проект?" |
|
||||
| Delete project | "Видалити проект '{name}'? Цю дію не можна скасувати." |
|
||||
| Delete site | "Видалити станцію '{name}'?" |
|
||||
| Delete sector | "Видалити сектор '{name}'?" |
|
||||
| New project (when dirty) | "Є незбережені зміни. Створити новий проект?" |
|
||||
| Page refresh (handled by beforeunload) | Browser native dialog |
|
||||
|
||||
---
|
||||
|
||||
### 3. Undo/Redo System
|
||||
|
||||
**Create history store:**
|
||||
|
||||
```typescript
|
||||
// src/store/historyStore.ts
|
||||
|
||||
interface HistoryState {
|
||||
past: ProjectSnapshot[];
|
||||
future: ProjectSnapshot[];
|
||||
maxHistory: number; // default: 50
|
||||
|
||||
// Actions
|
||||
push: (snapshot: ProjectSnapshot) => void;
|
||||
undo: () => ProjectSnapshot | null;
|
||||
redo: () => ProjectSnapshot | null;
|
||||
clear: () => void;
|
||||
|
||||
// Selectors
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
}
|
||||
|
||||
type ProjectSnapshot = {
|
||||
sites: Site[];
|
||||
settings: CoverageSettings;
|
||||
timestamp: number;
|
||||
action: string; // "add site", "delete sector", "move site", etc.
|
||||
};
|
||||
```
|
||||
|
||||
**Integration points — push snapshot BEFORE these actions:**
|
||||
|
||||
- Add site
|
||||
- Delete site
|
||||
- Update site (position, params)
|
||||
- Add sector
|
||||
- Delete sector
|
||||
- Update sector
|
||||
- Update coverage settings
|
||||
- Import project
|
||||
- Clear all sites
|
||||
|
||||
**Keyboard shortcuts:**
|
||||
|
||||
```typescript
|
||||
// src/hooks/useKeyboardShortcuts.ts
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
}
|
||||
|
||||
// Redo: Ctrl+Shift+Z or Ctrl+Y
|
||||
if ((e.ctrlKey || e.metaKey) && (
|
||||
(e.key === 'z' && e.shiftKey) ||
|
||||
e.key === 'y'
|
||||
)) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
```
|
||||
|
||||
**UI indicators:**
|
||||
|
||||
```typescript
|
||||
// Toolbar buttons (disabled when can't undo/redo)
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<RedoIcon />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Enhanced Toast Notifications
|
||||
|
||||
**Extend existing toast for all actions:**
|
||||
|
||||
```typescript
|
||||
// Success toasts
|
||||
"Проект збережено"
|
||||
"Станцію додано"
|
||||
"Станцію видалено"
|
||||
"Зміни скасовано" (undo)
|
||||
"Зміни повернено" (redo)
|
||||
|
||||
// Error toasts
|
||||
"Помилка збереження"
|
||||
"Помилка завантаження"
|
||||
|
||||
// Info toasts
|
||||
"Проект завантажено"
|
||||
```
|
||||
|
||||
**Toast with undo action (optional enhancement):**
|
||||
|
||||
```typescript
|
||||
// When deleting, show toast with undo button
|
||||
toast({
|
||||
message: "Станцію видалено",
|
||||
action: {
|
||||
label: "Скасувати",
|
||||
onClick: () => handleUndo()
|
||||
},
|
||||
duration: 5000
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files to Create/Modify
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/
|
||||
│ └── ConfirmDialog.tsx # NEW
|
||||
├── store/
|
||||
│ ├── historyStore.ts # NEW
|
||||
│ └── projectStore.ts # MODIFY (add isDirty)
|
||||
├── hooks/
|
||||
│ ├── useUnsavedChanges.ts # NEW
|
||||
│ └── useKeyboardShortcuts.ts # NEW or MODIFY
|
||||
└── App.tsx # MODIFY (add beforeunload)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Refresh page with unsaved changes → browser warning
|
||||
- [ ] Load project with unsaved changes → confirmation dialog
|
||||
- [ ] Delete project → confirmation dialog (danger style)
|
||||
- [ ] Delete site/sector → confirmation dialog
|
||||
- [ ] Ctrl+Z undoes last action
|
||||
- [ ] Ctrl+Shift+Z redoes
|
||||
- [ ] Undo/Redo buttons in toolbar (with disabled states)
|
||||
- [ ] Toast shows on save/delete/undo/redo
|
||||
- [ ] History limited to 50 states (memory management)
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test Scenarios
|
||||
|
||||
1. **Unsaved changes:**
|
||||
- Add site → refresh → browser warning appears
|
||||
- Add site → save → refresh → no warning
|
||||
|
||||
2. **Confirmations:**
|
||||
- Add site → click Load → dialog appears → Cancel → site still there
|
||||
- Add site → click Load → dialog appears → Confirm → new project loads
|
||||
|
||||
3. **Undo/Redo:**
|
||||
- Add 3 sites → Ctrl+Z → 2 sites remain
|
||||
- Ctrl+Z again → 1 site
|
||||
- Ctrl+Shift+Z → 2 sites back
|
||||
- Add new site after undo → redo history cleared
|
||||
|
||||
4. **Edge cases:**
|
||||
- Undo when nothing to undo → button disabled, no action
|
||||
- 51 actions → oldest dropped from history
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Keep snapshots lightweight (only sites + settings, not UI state)
|
||||
- Debounce position changes (don't snapshot every pixel of drag)
|
||||
- Consider grouping rapid changes (e.g., typing in input)
|
||||
- Ukrainian UI text for all dialogs/toasts
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
653
docs/devlog/back/RFCP-Iteration-1.2-Terrain-Integration.md
Normal file
653
docs/devlog/back/RFCP-Iteration-1.2-Terrain-Integration.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# RFCP Backend - Iteration 1.2: Terrain Integration
|
||||
|
||||
**Date:** January 30, 2025
|
||||
**Type:** Backend Development
|
||||
**Estimated:** 4-6 hours (Phase 2.1 + 2.2 from roadmap)
|
||||
**Location:** `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Add SRTM elevation data service and Line-of-Sight calculations for realistic RF coverage.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-reading (IMPORTANT)
|
||||
|
||||
Before starting, read these documents in project knowledge:
|
||||
|
||||
1. `RFCP-Backend-Roadmap-Complete.md` — Phase 2 details (lines 312-600)
|
||||
2. `RFCP-Iteration-1.1-Backend-Foundation.md` — current state
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
```bash
|
||||
# Backend running with 1.1 complete
|
||||
systemctl status rfcp-backend.service # ✅ active
|
||||
|
||||
# Structure from 1.1
|
||||
/opt/rfcp/backend/
|
||||
├── app/
|
||||
│ ├── main.py
|
||||
│ ├── api/routes/ # health.py, projects.py
|
||||
│ ├── core/ # config.py, database.py
|
||||
│ └── models/ # project.py, site.py
|
||||
├── venv/
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### 1. Install Additional Dependencies
|
||||
|
||||
```bash
|
||||
cd /opt/rfcp/backend
|
||||
source venv/bin/activate
|
||||
|
||||
pip install aiofiles httpx
|
||||
pip freeze > requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Create Data Directory
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/rfcp/backend/data/srtm
|
||||
chown -R root:root /opt/rfcp/backend/data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Create Terrain Service
|
||||
|
||||
**app/services/__init__.py:**
|
||||
```python
|
||||
# empty file
|
||||
```
|
||||
|
||||
**app/services/terrain_service.py:**
|
||||
```python
|
||||
import struct
|
||||
import asyncio
|
||||
import aiofiles
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
import numpy as np
|
||||
|
||||
class TerrainService:
|
||||
"""
|
||||
SRTM elevation data service
|
||||
- Downloads and caches .hgt tiles
|
||||
- Provides elevation lookups
|
||||
- Generates elevation profiles
|
||||
"""
|
||||
|
||||
# SRTM tile dimensions (1 arc-second = 3601x3601, 3 arc-second = 1201x1201)
|
||||
TILE_SIZE = 3601 # 1 arc-second (30m resolution)
|
||||
|
||||
# Mirror URLs for SRTM data (USGS requires login, use mirrors)
|
||||
SRTM_MIRRORS = [
|
||||
"https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_dir}/{tile_name}.hgt.gz",
|
||||
]
|
||||
|
||||
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/srtm"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
self._tile_cache: dict[str, np.ndarray] = {} # In-memory cache
|
||||
self._max_cached_tiles = 10 # Limit memory usage
|
||||
|
||||
def get_tile_name(self, lat: float, lon: float) -> str:
|
||||
"""Convert lat/lon to SRTM tile name (e.g., N48E035)"""
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
|
||||
lat_letter = 'N' if lat_int >= 0 else 'S'
|
||||
lon_letter = 'E' if lon_int >= 0 else 'W'
|
||||
|
||||
return f"{lat_letter}{abs(lat_int):02d}{lon_letter}{abs(lon_int):03d}"
|
||||
|
||||
def get_tile_path(self, tile_name: str) -> Path:
|
||||
"""Get local path for tile"""
|
||||
return self.cache_dir / f"{tile_name}.hgt"
|
||||
|
||||
async def download_tile(self, tile_name: str) -> bool:
|
||||
"""Download SRTM tile from mirror"""
|
||||
import gzip
|
||||
|
||||
tile_path = self.get_tile_path(tile_name)
|
||||
if tile_path.exists():
|
||||
return True
|
||||
|
||||
lat_dir = tile_name[:3] # e.g., "N48"
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
for mirror in self.SRTM_MIRRORS:
|
||||
url = mirror.format(lat_dir=lat_dir, tile_name=tile_name)
|
||||
try:
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
# Decompress gzip
|
||||
decompressed = gzip.decompress(response.content)
|
||||
|
||||
async with aiofiles.open(tile_path, 'wb') as f:
|
||||
await f.write(decompressed)
|
||||
|
||||
print(f"Downloaded {tile_name} from {mirror}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to download from {mirror}: {e}")
|
||||
continue
|
||||
|
||||
print(f"Failed to download tile {tile_name}")
|
||||
return False
|
||||
|
||||
async def load_tile(self, tile_name: str) -> Optional[np.ndarray]:
|
||||
"""Load tile into memory (with caching)"""
|
||||
# Check memory cache
|
||||
if tile_name in self._tile_cache:
|
||||
return self._tile_cache[tile_name]
|
||||
|
||||
tile_path = self.get_tile_path(tile_name)
|
||||
|
||||
# Download if missing
|
||||
if not tile_path.exists():
|
||||
success = await self.download_tile(tile_name)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
# Read HGT file (big-endian signed 16-bit integers)
|
||||
try:
|
||||
async with aiofiles.open(tile_path, 'rb') as f:
|
||||
data = await f.read()
|
||||
|
||||
# Parse as numpy array
|
||||
arr = np.frombuffer(data, dtype='>i2').reshape(self.TILE_SIZE, self.TILE_SIZE)
|
||||
|
||||
# Manage cache size
|
||||
if len(self._tile_cache) >= self._max_cached_tiles:
|
||||
# Remove oldest entry
|
||||
oldest = next(iter(self._tile_cache))
|
||||
del self._tile_cache[oldest]
|
||||
|
||||
self._tile_cache[tile_name] = arr
|
||||
return arr
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading tile {tile_name}: {e}")
|
||||
return None
|
||||
|
||||
async def get_elevation(self, lat: float, lon: float) -> float:
|
||||
"""Get elevation at specific coordinate (meters above sea level)"""
|
||||
tile_name = self.get_tile_name(lat, lon)
|
||||
tile = await self.load_tile(tile_name)
|
||||
|
||||
if tile is None:
|
||||
return 0.0 # No data, assume sea level
|
||||
|
||||
# Calculate position within tile
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
|
||||
lat_frac = lat - lat_int
|
||||
lon_frac = lon - lon_int
|
||||
|
||||
# Row 0 = north edge, row 3600 = south edge
|
||||
row = int((1 - lat_frac) * (self.TILE_SIZE - 1))
|
||||
col = int(lon_frac * (self.TILE_SIZE - 1))
|
||||
|
||||
# Clamp to valid range
|
||||
row = max(0, min(row, self.TILE_SIZE - 1))
|
||||
col = max(0, min(col, self.TILE_SIZE - 1))
|
||||
|
||||
elevation = tile[row, col]
|
||||
|
||||
# -32768 = void/no data
|
||||
if elevation == -32768:
|
||||
return 0.0
|
||||
|
||||
return float(elevation)
|
||||
|
||||
async def get_elevation_profile(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float,
|
||||
num_points: int = 100
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Get elevation profile between two points
|
||||
|
||||
Returns list of {lat, lon, elevation, distance} dicts
|
||||
"""
|
||||
lats = np.linspace(lat1, lat2, num_points)
|
||||
lons = np.linspace(lon1, lon2, num_points)
|
||||
|
||||
# Calculate cumulative distances
|
||||
total_distance = self.haversine_distance(lat1, lon1, lat2, lon2)
|
||||
distances = np.linspace(0, total_distance, num_points)
|
||||
|
||||
profile = []
|
||||
for i, (lat, lon, dist) in enumerate(zip(lats, lons, distances)):
|
||||
elev = await self.get_elevation(lat, lon)
|
||||
profile.append({
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
"elevation": elev,
|
||||
"distance": float(dist)
|
||||
})
|
||||
|
||||
return profile
|
||||
|
||||
@staticmethod
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance between two points in meters"""
|
||||
EARTH_RADIUS = 6371000 # meters
|
||||
|
||||
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
|
||||
a = np.sin(dlat/2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon/2)**2
|
||||
c = 2 * np.arcsin(np.sqrt(a))
|
||||
|
||||
return EARTH_RADIUS * c
|
||||
|
||||
|
||||
# Singleton instance
|
||||
terrain_service = TerrainService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Create Line-of-Sight Service
|
||||
|
||||
**app/services/los_service.py:**
|
||||
```python
|
||||
import numpy as np
|
||||
from typing import Tuple, List
|
||||
from app.services.terrain_service import terrain_service, TerrainService
|
||||
|
||||
class LineOfSightService:
|
||||
"""
|
||||
Line-of-Sight calculations with terrain
|
||||
"""
|
||||
|
||||
EARTH_RADIUS = 6371000 # meters
|
||||
K_FACTOR = 4/3 # Standard atmospheric refraction
|
||||
|
||||
def __init__(self, terrain: TerrainService = None):
|
||||
self.terrain = terrain or terrain_service
|
||||
|
||||
async def check_line_of_sight(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float = 1.5,
|
||||
num_samples: int = 50
|
||||
) -> dict:
|
||||
"""
|
||||
Check line-of-sight between transmitter and receiver
|
||||
|
||||
Args:
|
||||
tx_lat, tx_lon: Transmitter coordinates
|
||||
tx_height: Transmitter antenna height above ground (meters)
|
||||
rx_lat, rx_lon: Receiver coordinates
|
||||
rx_height: Receiver height above ground (meters), default 1.5m (person)
|
||||
num_samples: Number of points to sample along path
|
||||
|
||||
Returns:
|
||||
{
|
||||
"has_los": bool,
|
||||
"clearance": float, # minimum clearance in meters (negative = blocked)
|
||||
"blocked_at": float | None, # distance where blocked (meters)
|
||||
"profile": [...] # elevation profile with LOS line
|
||||
}
|
||||
"""
|
||||
# Get elevation profile
|
||||
profile = await self.terrain.get_elevation_profile(
|
||||
tx_lat, tx_lon, rx_lat, rx_lon, num_samples
|
||||
)
|
||||
|
||||
if not profile:
|
||||
return {"has_los": True, "clearance": 0, "blocked_at": None, "profile": []}
|
||||
|
||||
# Get endpoint elevations
|
||||
tx_ground = profile[0]["elevation"]
|
||||
rx_ground = profile[-1]["elevation"]
|
||||
|
||||
tx_total = tx_ground + tx_height
|
||||
rx_total = rx_ground + rx_height
|
||||
|
||||
total_distance = profile[-1]["distance"]
|
||||
|
||||
min_clearance = float('inf')
|
||||
blocked_at = None
|
||||
|
||||
# Check each point along path
|
||||
for point in profile:
|
||||
d = point["distance"]
|
||||
terrain_elev = point["elevation"]
|
||||
|
||||
if total_distance == 0:
|
||||
los_height = tx_total
|
||||
else:
|
||||
# Linear interpolation of LOS line
|
||||
los_height = tx_total + (rx_total - tx_total) * (d / total_distance)
|
||||
|
||||
# Earth curvature correction (with atmospheric refraction)
|
||||
# Effective Earth radius = K * actual radius
|
||||
effective_radius = self.K_FACTOR * self.EARTH_RADIUS
|
||||
curvature = (d * (total_distance - d)) / (2 * effective_radius)
|
||||
|
||||
# LOS height after curvature correction
|
||||
los_height_corrected = los_height - curvature
|
||||
|
||||
# Clearance at this point
|
||||
clearance = los_height_corrected - terrain_elev
|
||||
|
||||
# Add to profile for visualization
|
||||
point["los_height"] = los_height_corrected
|
||||
point["clearance"] = clearance
|
||||
|
||||
if clearance < min_clearance:
|
||||
min_clearance = clearance
|
||||
if clearance <= 0:
|
||||
blocked_at = d
|
||||
|
||||
has_los = min_clearance > 0
|
||||
|
||||
return {
|
||||
"has_los": has_los,
|
||||
"clearance": min_clearance,
|
||||
"blocked_at": blocked_at,
|
||||
"profile": profile
|
||||
}
|
||||
|
||||
async def calculate_fresnel_clearance(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
num_samples: int = 50
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate Fresnel zone clearance
|
||||
|
||||
60% clearance of 1st Fresnel zone = good signal
|
||||
|
||||
Returns:
|
||||
{
|
||||
"clearance_percent": float, # worst-case clearance as % of required
|
||||
"has_adequate_clearance": bool, # >= 60%
|
||||
"worst_point_distance": float,
|
||||
"fresnel_profile": [...]
|
||||
}
|
||||
"""
|
||||
profile = await self.terrain.get_elevation_profile(
|
||||
tx_lat, tx_lon, rx_lat, rx_lon, num_samples
|
||||
)
|
||||
|
||||
if not profile:
|
||||
return {
|
||||
"clearance_percent": 100.0,
|
||||
"has_adequate_clearance": True,
|
||||
"worst_point_distance": 0,
|
||||
"fresnel_profile": []
|
||||
}
|
||||
|
||||
tx_ground = profile[0]["elevation"]
|
||||
rx_ground = profile[-1]["elevation"]
|
||||
|
||||
tx_total = tx_ground + tx_height
|
||||
rx_total = rx_ground + rx_height
|
||||
|
||||
total_distance = profile[-1]["distance"]
|
||||
|
||||
# Wavelength (λ = c / f)
|
||||
wavelength = 300.0 / frequency_mhz # meters
|
||||
|
||||
worst_clearance_pct = 100.0
|
||||
worst_distance = 0.0
|
||||
|
||||
for point in profile:
|
||||
d = point["distance"]
|
||||
terrain_elev = point["elevation"]
|
||||
|
||||
if d == 0 or d == total_distance:
|
||||
continue # Skip endpoints
|
||||
|
||||
# LOS height at this point
|
||||
if total_distance > 0:
|
||||
los_height = tx_total + (rx_total - tx_total) * (d / total_distance)
|
||||
else:
|
||||
los_height = tx_total
|
||||
|
||||
# 1st Fresnel zone radius at this point
|
||||
d1 = d
|
||||
d2 = total_distance - d
|
||||
fresnel_radius = np.sqrt((wavelength * d1 * d2) / total_distance)
|
||||
|
||||
# Required clearance (60% of 1st Fresnel zone)
|
||||
required_clearance = 0.6 * fresnel_radius
|
||||
|
||||
# Actual clearance
|
||||
actual_clearance = los_height - terrain_elev
|
||||
|
||||
# Clearance as percentage of required
|
||||
if required_clearance > 0:
|
||||
clearance_pct = (actual_clearance / required_clearance) * 100
|
||||
else:
|
||||
clearance_pct = 100.0
|
||||
|
||||
# Add to profile
|
||||
point["fresnel_radius"] = fresnel_radius
|
||||
point["required_clearance"] = required_clearance
|
||||
point["clearance_percent"] = clearance_pct
|
||||
|
||||
if clearance_pct < worst_clearance_pct:
|
||||
worst_clearance_pct = clearance_pct
|
||||
worst_distance = d
|
||||
|
||||
return {
|
||||
"clearance_percent": worst_clearance_pct,
|
||||
"has_adequate_clearance": worst_clearance_pct >= 60.0,
|
||||
"worst_point_distance": worst_distance,
|
||||
"fresnel_profile": profile
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
los_service = LineOfSightService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Create Terrain API Routes
|
||||
|
||||
**app/api/routes/terrain.py:**
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
from app.services.terrain_service import terrain_service
|
||||
from app.services.los_service import los_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/elevation")
|
||||
async def get_elevation(
|
||||
lat: float = Query(..., ge=-90, le=90, description="Latitude"),
|
||||
lon: float = Query(..., ge=-180, le=180, description="Longitude")
|
||||
):
|
||||
"""Get elevation at a specific point"""
|
||||
elevation = await terrain_service.get_elevation(lat, lon)
|
||||
return {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"elevation": elevation,
|
||||
"unit": "meters"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
async def get_elevation_profile(
|
||||
lat1: float = Query(..., description="Start latitude"),
|
||||
lon1: float = Query(..., description="Start longitude"),
|
||||
lat2: float = Query(..., description="End latitude"),
|
||||
lon2: float = Query(..., description="End longitude"),
|
||||
points: int = Query(100, ge=10, le=500, description="Number of sample points")
|
||||
):
|
||||
"""Get elevation profile between two points"""
|
||||
profile = await terrain_service.get_elevation_profile(lat1, lon1, lat2, lon2, points)
|
||||
|
||||
return {
|
||||
"start": {"lat": lat1, "lon": lon1},
|
||||
"end": {"lat": lat2, "lon": lon2},
|
||||
"num_points": len(profile),
|
||||
"profile": profile
|
||||
}
|
||||
|
||||
|
||||
@router.get("/los")
|
||||
async def check_line_of_sight(
|
||||
tx_lat: float = Query(..., description="Transmitter latitude"),
|
||||
tx_lon: float = Query(..., description="Transmitter longitude"),
|
||||
tx_height: float = Query(..., ge=0, description="Transmitter height above ground (m)"),
|
||||
rx_lat: float = Query(..., description="Receiver latitude"),
|
||||
rx_lon: float = Query(..., description="Receiver longitude"),
|
||||
rx_height: float = Query(1.5, ge=0, description="Receiver height above ground (m)")
|
||||
):
|
||||
"""Check line-of-sight between transmitter and receiver"""
|
||||
result = await los_service.check_line_of_sight(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/fresnel")
|
||||
async def check_fresnel_clearance(
|
||||
tx_lat: float = Query(..., description="Transmitter latitude"),
|
||||
tx_lon: float = Query(..., description="Transmitter longitude"),
|
||||
tx_height: float = Query(..., ge=0, description="Transmitter height (m)"),
|
||||
rx_lat: float = Query(..., description="Receiver latitude"),
|
||||
rx_lon: float = Query(..., description="Receiver longitude"),
|
||||
rx_height: float = Query(1.5, ge=0, description="Receiver height (m)"),
|
||||
frequency: float = Query(..., ge=100, le=6000, description="Frequency (MHz)")
|
||||
):
|
||||
"""Calculate Fresnel zone clearance"""
|
||||
result = await los_service.calculate_fresnel_clearance(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tiles")
|
||||
async def list_cached_tiles():
|
||||
"""List cached SRTM tiles"""
|
||||
tiles = list(terrain_service.cache_dir.glob("*.hgt"))
|
||||
return {
|
||||
"cache_dir": str(terrain_service.cache_dir),
|
||||
"tiles": [t.stem for t in tiles],
|
||||
"count": len(tiles)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Register Routes in main.py
|
||||
|
||||
**Update app/main.py:**
|
||||
```python
|
||||
# Add import
|
||||
from app.api.routes import health, projects, terrain
|
||||
|
||||
# Add router
|
||||
app.include_router(terrain.router, prefix="/api/terrain", tags=["terrain"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Restart & Test
|
||||
|
||||
```bash
|
||||
# Install deps
|
||||
cd /opt/rfcp/backend
|
||||
source venv/bin/activate
|
||||
pip install aiofiles httpx
|
||||
|
||||
# Restart service
|
||||
sudo systemctl restart rfcp-backend.service
|
||||
|
||||
# Check logs
|
||||
journalctl -u rfcp-backend -f
|
||||
|
||||
# Test endpoints
|
||||
curl "https://api.rfcp.eliah.one/api/terrain/elevation?lat=48.5&lon=35.0"
|
||||
|
||||
curl "https://api.rfcp.eliah.one/api/terrain/profile?lat1=48.5&lon1=35.0&lat2=48.6&lon2=35.1&points=50"
|
||||
|
||||
curl "https://api.rfcp.eliah.one/api/terrain/los?tx_lat=48.5&tx_lon=35.0&tx_height=30&rx_lat=48.52&rx_lon=35.02"
|
||||
|
||||
curl "https://api.rfcp.eliah.one/api/terrain/fresnel?tx_lat=48.5&tx_lon=35.0&tx_height=30&rx_lat=48.52&rx_lon=35.02&frequency=1800"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] `/api/terrain/elevation` returns elevation for any Ukraine coordinate
|
||||
- [ ] SRTM tiles auto-download to `/opt/rfcp/backend/data/srtm/`
|
||||
- [ ] `/api/terrain/profile` returns elevation array between two points
|
||||
- [ ] `/api/terrain/los` returns `has_los: true/false` with clearance
|
||||
- [ ] `/api/terrain/fresnel` returns clearance percentage
|
||||
- [ ] In-memory tile cache working (≤10 tiles)
|
||||
- [ ] Swagger docs show all new endpoints
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Created
|
||||
|
||||
```
|
||||
app/services/
|
||||
├── __init__.py # NEW
|
||||
├── terrain_service.py # NEW - SRTM handling
|
||||
└── los_service.py # NEW - LoS + Fresnel
|
||||
|
||||
app/api/routes/
|
||||
└── terrain.py # NEW - API endpoints
|
||||
|
||||
data/srtm/
|
||||
└── *.hgt # Auto-downloaded tiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- SRTM tiles ~25MB each, auto-download on first request
|
||||
- Ukraine needs ~20-30 tiles for full coverage
|
||||
- First request to new area will be slow (download)
|
||||
- Subsequent requests fast (cached in memory + disk)
|
||||
- Earth curvature + atmospheric refraction (K=4/3) included
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next: Iteration 1.3
|
||||
|
||||
- Integrate terrain into coverage calculation
|
||||
- Add `use_terrain` flag to settings
|
||||
- Enhanced path loss with LoS/Fresnel factors
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
855
docs/devlog/back/RFCP-Iteration-1.3-Coverage-OSM-Buildings.md
Normal file
855
docs/devlog/back/RFCP-Iteration-1.3-Coverage-OSM-Buildings.md
Normal file
@@ -0,0 +1,855 @@
|
||||
# RFCP Backend - Iteration 1.3: Coverage Calculation + OSM Buildings
|
||||
|
||||
**Date:** January 30, 2025
|
||||
**Type:** Backend Development
|
||||
**Estimated:** 6-8 hours
|
||||
**Location:** `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Implement server-side coverage calculation with terrain (SRTM) and building obstacles (OpenStreetMap) for realistic urban RF propagation.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Pre-reading
|
||||
|
||||
1. `RFCP-Backend-Roadmap-Complete.md` — Phase 2 & 3 details
|
||||
2. `RFCP-Iteration-1.2-Terrain-Integration.md` — current terrain services
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
```bash
|
||||
# Backend 1.2 complete
|
||||
/opt/rfcp/backend/app/
|
||||
├── services/
|
||||
│ ├── terrain_service.py # SRTM elevation ✅
|
||||
│ └── los_service.py # Line-of-sight + Fresnel ✅
|
||||
├── api/routes/
|
||||
│ └── terrain.py # /elevation, /profile, /los, /fresnel ✅
|
||||
```
|
||||
|
||||
**What's missing:**
|
||||
- Building data (OSM)
|
||||
- Coverage grid calculation
|
||||
- Integration of terrain + buildings into RF model
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### 1. Create OSM Buildings Service
|
||||
|
||||
**app/services/buildings_service.py:**
|
||||
```python
|
||||
import httpx
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from functools import lru_cache
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
class Building(BaseModel):
|
||||
"""Single building footprint"""
|
||||
id: int
|
||||
geometry: List[List[float]] # [[lon, lat], ...]
|
||||
height: float # meters
|
||||
levels: Optional[int] = None
|
||||
building_type: Optional[str] = None
|
||||
|
||||
class BuildingsService:
|
||||
"""
|
||||
OpenStreetMap buildings via Overpass API
|
||||
"""
|
||||
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
DEFAULT_LEVEL_HEIGHT = 3.0 # meters per floor
|
||||
DEFAULT_BUILDING_HEIGHT = 9.0 # 3 floors if unknown
|
||||
|
||||
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/buildings"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
self._memory_cache: dict[str, List[Building]] = {}
|
||||
self._max_cache_size = 50 # bbox regions
|
||||
|
||||
def _bbox_key(self, min_lat: float, min_lon: float, max_lat: float, max_lon: float) -> str:
|
||||
"""Generate cache key for bbox"""
|
||||
# Round to 0.01 degree (~1km) grid for cache efficiency
|
||||
key = f"{min_lat:.2f},{min_lon:.2f},{max_lat:.2f},{max_lon:.2f}"
|
||||
return hashlib.md5(key.encode()).hexdigest()[:12]
|
||||
|
||||
async def fetch_buildings(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
max_lat: float, max_lon: float,
|
||||
use_cache: bool = True
|
||||
) -> List[Building]:
|
||||
"""
|
||||
Fetch buildings in bounding box from OSM
|
||||
|
||||
Args:
|
||||
min_lat, min_lon, max_lat, max_lon: Bounding box
|
||||
use_cache: Whether to use cached results
|
||||
|
||||
Returns:
|
||||
List of Building objects with height estimates
|
||||
"""
|
||||
cache_key = self._bbox_key(min_lat, min_lon, max_lat, max_lon)
|
||||
|
||||
# Check memory cache
|
||||
if use_cache and cache_key in self._memory_cache:
|
||||
return self._memory_cache[cache_key]
|
||||
|
||||
# Check disk cache
|
||||
cache_file = self.cache_dir / f"{cache_key}.json"
|
||||
if use_cache and cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
buildings = [Building(**b) for b in data]
|
||||
self._memory_cache[cache_key] = buildings
|
||||
return buildings
|
||||
except Exception:
|
||||
pass # Fetch fresh if cache corrupted
|
||||
|
||||
# Fetch from Overpass API
|
||||
query = f"""
|
||||
[out:json][timeout:30];
|
||||
(
|
||||
way["building"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
relation["building"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
);
|
||||
out body;
|
||||
>;
|
||||
out skel qt;
|
||||
"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
self.OVERPASS_URL,
|
||||
data={"data": query}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
print(f"Overpass API error: {e}")
|
||||
return []
|
||||
|
||||
# Parse response
|
||||
buildings = self._parse_overpass_response(data)
|
||||
|
||||
# Cache results
|
||||
if buildings:
|
||||
# Disk cache
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump([b.model_dump() for b in buildings], f)
|
||||
|
||||
# Memory cache (with size limit)
|
||||
if len(self._memory_cache) >= self._max_cache_size:
|
||||
oldest = next(iter(self._memory_cache))
|
||||
del self._memory_cache[oldest]
|
||||
self._memory_cache[cache_key] = buildings
|
||||
|
||||
return buildings
|
||||
|
||||
def _parse_overpass_response(self, data: dict) -> List[Building]:
|
||||
"""Parse Overpass JSON response into Building objects"""
|
||||
buildings = []
|
||||
|
||||
# Build node lookup
|
||||
nodes = {}
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] == "node":
|
||||
nodes[element["id"]] = (element["lon"], element["lat"])
|
||||
|
||||
# Process ways (building footprints)
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] != "way":
|
||||
continue
|
||||
|
||||
tags = element.get("tags", {})
|
||||
if "building" not in tags:
|
||||
continue
|
||||
|
||||
# Get geometry
|
||||
geometry = []
|
||||
for node_id in element.get("nodes", []):
|
||||
if node_id in nodes:
|
||||
geometry.append(list(nodes[node_id]))
|
||||
|
||||
if len(geometry) < 3:
|
||||
continue # Invalid polygon
|
||||
|
||||
# Estimate height
|
||||
height = self._estimate_height(tags)
|
||||
|
||||
buildings.append(Building(
|
||||
id=element["id"],
|
||||
geometry=geometry,
|
||||
height=height,
|
||||
levels=int(tags.get("building:levels", 0)) or None,
|
||||
building_type=tags.get("building")
|
||||
))
|
||||
|
||||
return buildings
|
||||
|
||||
def _estimate_height(self, tags: dict) -> float:
|
||||
"""Estimate building height from OSM tags"""
|
||||
# Explicit height tag
|
||||
if "height" in tags:
|
||||
try:
|
||||
h = tags["height"]
|
||||
# Handle "10 m" or "10m" format
|
||||
if isinstance(h, str):
|
||||
h = h.replace("m", "").replace(" ", "")
|
||||
return float(h)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Calculate from levels
|
||||
if "building:levels" in tags:
|
||||
try:
|
||||
levels = int(tags["building:levels"])
|
||||
return levels * self.DEFAULT_LEVEL_HEIGHT
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Default based on building type
|
||||
building_type = tags.get("building", "yes")
|
||||
type_heights = {
|
||||
"house": 6.0,
|
||||
"residential": 12.0,
|
||||
"apartments": 18.0,
|
||||
"commercial": 12.0,
|
||||
"industrial": 8.0,
|
||||
"warehouse": 6.0,
|
||||
"garage": 3.0,
|
||||
"shed": 2.5,
|
||||
"roof": 3.0,
|
||||
"church": 15.0,
|
||||
"cathedral": 30.0,
|
||||
"hospital": 15.0,
|
||||
"school": 12.0,
|
||||
"university": 15.0,
|
||||
"office": 20.0,
|
||||
"retail": 6.0,
|
||||
}
|
||||
|
||||
return type_heights.get(building_type, self.DEFAULT_BUILDING_HEIGHT)
|
||||
|
||||
def point_in_building(self, lat: float, lon: float, building: Building) -> bool:
|
||||
"""Check if point is inside building footprint (ray casting)"""
|
||||
x, y = lon, lat
|
||||
polygon = building.geometry
|
||||
n = len(polygon)
|
||||
inside = False
|
||||
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = polygon[i]
|
||||
xj, yj = polygon[j]
|
||||
|
||||
if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
|
||||
return inside
|
||||
|
||||
def line_intersects_building(
|
||||
self,
|
||||
lat1: float, lon1: float, height1: float,
|
||||
lat2: float, lon2: float, height2: float,
|
||||
building: Building
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Check if line segment intersects building
|
||||
|
||||
Returns:
|
||||
Distance along path where intersection occurs, or None
|
||||
"""
|
||||
# Simplified 2D check + height comparison
|
||||
# For accurate 3D intersection, would need proper ray-polygon intersection
|
||||
|
||||
from app.services.terrain_service import TerrainService
|
||||
|
||||
# Sample points along line
|
||||
num_samples = 20
|
||||
for i in range(num_samples):
|
||||
t = i / num_samples
|
||||
lat = lat1 + t * (lat2 - lat1)
|
||||
lon = lon1 + t * (lon2 - lon1)
|
||||
height = height1 + t * (height2 - height1)
|
||||
|
||||
if self.point_in_building(lat, lon, building):
|
||||
# Check if signal height is below building
|
||||
if height < building.height:
|
||||
# Calculate distance
|
||||
dist = t * TerrainService.haversine_distance(lat1, lon1, lat2, lon2)
|
||||
return dist
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
buildings_service = BuildingsService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Create Coverage Calculation Service
|
||||
|
||||
**app/services/coverage_service.py:**
|
||||
```python
|
||||
import numpy as np
|
||||
import asyncio
|
||||
from typing import List, Optional, Tuple
|
||||
from pydantic import BaseModel
|
||||
from app.services.terrain_service import terrain_service, TerrainService
|
||||
from app.services.los_service import los_service
|
||||
from app.services.buildings_service import buildings_service, Building
|
||||
|
||||
class CoveragePoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
rsrp: float # dBm
|
||||
distance: float # meters from site
|
||||
has_los: bool
|
||||
terrain_loss: float # dB
|
||||
building_loss: float # dB
|
||||
|
||||
class CoverageSettings(BaseModel):
|
||||
radius: float = 10000 # meters
|
||||
resolution: float = 200 # meters
|
||||
min_signal: float = -120 # dBm threshold
|
||||
use_terrain: bool = True
|
||||
use_buildings: bool = True
|
||||
|
||||
class SiteParams(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
height: float = 30 # antenna height meters
|
||||
power: float = 43 # dBm (20W)
|
||||
gain: float = 15 # dBi
|
||||
frequency: float = 1800 # MHz
|
||||
azimuth: Optional[float] = None # degrees, None = omni
|
||||
beamwidth: Optional[float] = 65 # degrees
|
||||
|
||||
class CoverageService:
|
||||
"""
|
||||
RF Coverage calculation with terrain and buildings
|
||||
"""
|
||||
|
||||
EARTH_RADIUS = 6371000
|
||||
|
||||
def __init__(self):
|
||||
self.terrain = terrain_service
|
||||
self.buildings = buildings_service
|
||||
self.los = los_service
|
||||
|
||||
async def calculate_coverage(
|
||||
self,
|
||||
site: SiteParams,
|
||||
settings: CoverageSettings
|
||||
) -> List[CoveragePoint]:
|
||||
"""
|
||||
Calculate coverage grid for a single site
|
||||
|
||||
Returns list of CoveragePoint with RSRP values
|
||||
"""
|
||||
points = []
|
||||
|
||||
# Generate grid
|
||||
grid = self._generate_grid(
|
||||
site.lat, site.lon,
|
||||
settings.radius,
|
||||
settings.resolution
|
||||
)
|
||||
|
||||
# Fetch buildings for coverage area (if enabled)
|
||||
buildings = []
|
||||
if settings.use_buildings:
|
||||
# Calculate bbox with margin
|
||||
lat_delta = settings.radius / 111000 # ~111km per degree
|
||||
lon_delta = settings.radius / (111000 * np.cos(np.radians(site.lat)))
|
||||
|
||||
buildings = await self.buildings.fetch_buildings(
|
||||
site.lat - lat_delta, site.lon - lon_delta,
|
||||
site.lat + lat_delta, site.lon + lon_delta
|
||||
)
|
||||
|
||||
# Calculate coverage for each point
|
||||
for lat, lon in grid:
|
||||
point = await self._calculate_point(
|
||||
site, lat, lon,
|
||||
settings, buildings
|
||||
)
|
||||
|
||||
if point.rsrp >= settings.min_signal:
|
||||
points.append(point)
|
||||
|
||||
return points
|
||||
|
||||
async def calculate_multi_site_coverage(
|
||||
self,
|
||||
sites: List[SiteParams],
|
||||
settings: CoverageSettings
|
||||
) -> List[CoveragePoint]:
|
||||
"""
|
||||
Calculate combined coverage from multiple sites
|
||||
Best server (strongest signal) wins at each point
|
||||
"""
|
||||
if not sites:
|
||||
return []
|
||||
|
||||
# Get all individual coverages
|
||||
all_coverages = await asyncio.gather(*[
|
||||
self.calculate_coverage(site, settings)
|
||||
for site in sites
|
||||
])
|
||||
|
||||
# Combine by best signal
|
||||
point_map: dict[Tuple[float, float], CoveragePoint] = {}
|
||||
|
||||
for coverage in all_coverages:
|
||||
for point in coverage:
|
||||
key = (round(point.lat, 6), round(point.lon, 6))
|
||||
|
||||
if key not in point_map or point.rsrp > point_map[key].rsrp:
|
||||
point_map[key] = point
|
||||
|
||||
return list(point_map.values())
|
||||
|
||||
def _generate_grid(
|
||||
self,
|
||||
center_lat: float, center_lon: float,
|
||||
radius: float, resolution: float
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""Generate coverage grid points"""
|
||||
points = []
|
||||
|
||||
# Convert resolution to degrees
|
||||
lat_step = resolution / 111000
|
||||
lon_step = resolution / (111000 * np.cos(np.radians(center_lat)))
|
||||
|
||||
# Calculate grid bounds
|
||||
lat_delta = radius / 111000
|
||||
lon_delta = radius / (111000 * np.cos(np.radians(center_lat)))
|
||||
|
||||
lat = center_lat - lat_delta
|
||||
while lat <= center_lat + lat_delta:
|
||||
lon = center_lon - lon_delta
|
||||
while lon <= center_lon + lon_delta:
|
||||
# Check if within radius (circular, not square)
|
||||
dist = TerrainService.haversine_distance(center_lat, center_lon, lat, lon)
|
||||
if dist <= radius:
|
||||
points.append((lat, lon))
|
||||
lon += lon_step
|
||||
lat += lat_step
|
||||
|
||||
return points
|
||||
|
||||
async def _calculate_point(
|
||||
self,
|
||||
site: SiteParams,
|
||||
lat: float, lon: float,
|
||||
settings: CoverageSettings,
|
||||
buildings: List[Building]
|
||||
) -> CoveragePoint:
|
||||
"""Calculate RSRP at a single point"""
|
||||
|
||||
# Distance
|
||||
distance = TerrainService.haversine_distance(site.lat, site.lon, lat, lon)
|
||||
|
||||
if distance < 1:
|
||||
distance = 1 # Avoid division by zero
|
||||
|
||||
# Base path loss (Okumura-Hata for urban)
|
||||
path_loss = self._okumura_hata(
|
||||
distance, site.frequency, site.height, 1.5 # 1.5m receiver height
|
||||
)
|
||||
|
||||
# Antenna pattern loss (if directional)
|
||||
antenna_loss = 0.0
|
||||
if site.azimuth is not None and site.beamwidth:
|
||||
antenna_loss = self._antenna_pattern_loss(
|
||||
site.lat, site.lon, lat, lon,
|
||||
site.azimuth, site.beamwidth
|
||||
)
|
||||
|
||||
# Terrain loss (LoS check)
|
||||
terrain_loss = 0.0
|
||||
has_los = True
|
||||
|
||||
if settings.use_terrain:
|
||||
los_result = await self.los.check_line_of_sight(
|
||||
site.lat, site.lon, site.height,
|
||||
lat, lon, 1.5 # receiver at 1.5m
|
||||
)
|
||||
has_los = los_result["has_los"]
|
||||
|
||||
if not has_los:
|
||||
# Add diffraction loss based on clearance
|
||||
clearance = los_result["clearance"]
|
||||
terrain_loss = self._diffraction_loss(clearance, site.frequency)
|
||||
|
||||
# Building loss
|
||||
building_loss = 0.0
|
||||
|
||||
if settings.use_buildings and buildings:
|
||||
for building in buildings:
|
||||
intersection = self.buildings.line_intersects_building(
|
||||
site.lat, site.lon, site.height + await self.terrain.get_elevation(site.lat, site.lon),
|
||||
lat, lon, 1.5 + await self.terrain.get_elevation(lat, lon),
|
||||
building
|
||||
)
|
||||
if intersection is not None:
|
||||
# Building penetration loss (~20dB for concrete)
|
||||
building_loss += 20.0
|
||||
has_los = False
|
||||
break # One building is enough
|
||||
|
||||
# Calculate RSRP
|
||||
# RSRP = Tx Power + Tx Gain - Path Loss - Antenna Loss - Terrain Loss - Building Loss
|
||||
rsrp = site.power + site.gain - path_loss - antenna_loss - terrain_loss - building_loss
|
||||
|
||||
return CoveragePoint(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
rsrp=rsrp,
|
||||
distance=distance,
|
||||
has_los=has_los,
|
||||
terrain_loss=terrain_loss,
|
||||
building_loss=building_loss
|
||||
)
|
||||
|
||||
def _okumura_hata(
|
||||
self,
|
||||
distance: float, # meters
|
||||
frequency: float, # MHz
|
||||
tx_height: float, # meters
|
||||
rx_height: float # meters
|
||||
) -> float:
|
||||
"""
|
||||
Okumura-Hata path loss model (urban)
|
||||
|
||||
Returns path loss in dB
|
||||
"""
|
||||
d_km = distance / 1000
|
||||
|
||||
if d_km < 0.1:
|
||||
d_km = 0.1 # Minimum distance
|
||||
|
||||
# Mobile antenna height correction (urban)
|
||||
a_hm = (1.1 * np.log10(frequency) - 0.7) * rx_height - (1.56 * np.log10(frequency) - 0.8)
|
||||
|
||||
# Path loss
|
||||
L = (69.55 + 26.16 * np.log10(frequency) - 13.82 * np.log10(tx_height) - a_hm +
|
||||
(44.9 - 6.55 * np.log10(tx_height)) * np.log10(d_km))
|
||||
|
||||
return L
|
||||
|
||||
def _antenna_pattern_loss(
|
||||
self,
|
||||
site_lat: float, site_lon: float,
|
||||
point_lat: float, point_lon: float,
|
||||
azimuth: float, beamwidth: float
|
||||
) -> float:
|
||||
"""Calculate antenna pattern attenuation"""
|
||||
# Calculate bearing from site to point
|
||||
bearing = self._calculate_bearing(site_lat, site_lon, point_lat, point_lon)
|
||||
|
||||
# Angle difference from main lobe
|
||||
angle_diff = abs(bearing - azimuth)
|
||||
if angle_diff > 180:
|
||||
angle_diff = 360 - angle_diff
|
||||
|
||||
# Simple cosine pattern approximation
|
||||
# 3dB beamwidth = angle where power drops to half
|
||||
half_beamwidth = beamwidth / 2
|
||||
|
||||
if angle_diff <= half_beamwidth:
|
||||
# Within main lobe - minimal loss
|
||||
loss = 3 * (angle_diff / half_beamwidth) ** 2
|
||||
else:
|
||||
# Outside main lobe - significant loss
|
||||
loss = 3 + 12 * ((angle_diff - half_beamwidth) / half_beamwidth) ** 2
|
||||
loss = min(loss, 25) # Cap at 25dB (back lobe)
|
||||
|
||||
return loss
|
||||
|
||||
def _calculate_bearing(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float
|
||||
) -> float:
|
||||
"""Calculate bearing from point 1 to point 2 (degrees)"""
|
||||
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
dlon = lon2 - lon1
|
||||
|
||||
x = np.sin(dlon) * np.cos(lat2)
|
||||
y = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon)
|
||||
|
||||
bearing = np.degrees(np.arctan2(x, y))
|
||||
|
||||
return (bearing + 360) % 360
|
||||
|
||||
def _diffraction_loss(self, clearance: float, frequency: float) -> float:
|
||||
"""
|
||||
Knife-edge diffraction loss
|
||||
|
||||
Args:
|
||||
clearance: Clearance in meters (negative = obstructed)
|
||||
frequency: Frequency in MHz
|
||||
|
||||
Returns:
|
||||
Additional loss in dB
|
||||
"""
|
||||
if clearance >= 0:
|
||||
return 0.0 # No obstruction
|
||||
|
||||
# Fresnel parameter approximation
|
||||
# v ≈ clearance * sqrt(2 / (λ * d))
|
||||
# Simplified: use clearance directly
|
||||
|
||||
v = abs(clearance) / 10 # Normalize
|
||||
|
||||
# Knife-edge loss approximation
|
||||
if v <= 0:
|
||||
loss = 0
|
||||
elif v < 2.4:
|
||||
loss = 6.02 + 9.11 * v - 1.27 * v**2
|
||||
else:
|
||||
loss = 13.0 + 20 * np.log10(v)
|
||||
|
||||
return min(loss, 40) # Cap at 40dB
|
||||
|
||||
|
||||
# Singleton
|
||||
coverage_service = CoverageService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Create Coverage API Routes
|
||||
|
||||
**app/api/routes/coverage.py:**
|
||||
```python
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.services.coverage_service import (
|
||||
coverage_service,
|
||||
CoverageSettings,
|
||||
SiteParams,
|
||||
CoveragePoint
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CoverageRequest(BaseModel):
|
||||
"""Request body for coverage calculation"""
|
||||
sites: List[SiteParams]
|
||||
settings: CoverageSettings = CoverageSettings()
|
||||
|
||||
|
||||
class CoverageResponse(BaseModel):
|
||||
"""Coverage calculation response"""
|
||||
points: List[CoveragePoint]
|
||||
count: int
|
||||
settings: CoverageSettings
|
||||
stats: dict
|
||||
|
||||
|
||||
@router.post("/calculate")
|
||||
async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
||||
"""
|
||||
Calculate RF coverage for one or more sites
|
||||
|
||||
Returns grid of RSRP values with terrain and building effects
|
||||
"""
|
||||
if not request.sites:
|
||||
raise HTTPException(400, "At least one site required")
|
||||
|
||||
if len(request.sites) > 10:
|
||||
raise HTTPException(400, "Maximum 10 sites per request")
|
||||
|
||||
# Validate settings
|
||||
if request.settings.radius > 50000:
|
||||
raise HTTPException(400, "Maximum radius 50km")
|
||||
|
||||
if request.settings.resolution < 50:
|
||||
raise HTTPException(400, "Minimum resolution 50m")
|
||||
|
||||
# Calculate
|
||||
if len(request.sites) == 1:
|
||||
points = await coverage_service.calculate_coverage(
|
||||
request.sites[0],
|
||||
request.settings
|
||||
)
|
||||
else:
|
||||
points = await coverage_service.calculate_multi_site_coverage(
|
||||
request.sites,
|
||||
request.settings
|
||||
)
|
||||
|
||||
# Calculate stats
|
||||
rsrp_values = [p.rsrp for p in points]
|
||||
los_count = sum(1 for p in points if p.has_los)
|
||||
|
||||
stats = {
|
||||
"min_rsrp": min(rsrp_values) if rsrp_values else 0,
|
||||
"max_rsrp": max(rsrp_values) if rsrp_values else 0,
|
||||
"avg_rsrp": sum(rsrp_values) / len(rsrp_values) if rsrp_values else 0,
|
||||
"los_percentage": (los_count / len(points) * 100) if points else 0,
|
||||
"points_with_buildings": sum(1 for p in points if p.building_loss > 0),
|
||||
"points_with_terrain_loss": sum(1 for p in points if p.terrain_loss > 0),
|
||||
}
|
||||
|
||||
return CoverageResponse(
|
||||
points=points,
|
||||
count=len(points),
|
||||
settings=request.settings,
|
||||
stats=stats
|
||||
)
|
||||
|
||||
|
||||
@router.get("/buildings")
|
||||
async def get_buildings(
|
||||
min_lat: float,
|
||||
min_lon: float,
|
||||
max_lat: float,
|
||||
max_lon: float
|
||||
):
|
||||
"""
|
||||
Get buildings in bounding box (for debugging/visualization)
|
||||
"""
|
||||
from app.services.buildings_service import buildings_service
|
||||
|
||||
# Limit bbox size
|
||||
if (max_lat - min_lat) > 0.1 or (max_lon - min_lon) > 0.1:
|
||||
raise HTTPException(400, "Bbox too large (max 0.1 degrees)")
|
||||
|
||||
buildings = await buildings_service.fetch_buildings(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
|
||||
return {
|
||||
"count": len(buildings),
|
||||
"buildings": [b.model_dump() for b in buildings]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Register Routes
|
||||
|
||||
**Update app/main.py:**
|
||||
```python
|
||||
# Add import
|
||||
from app.api.routes import health, projects, terrain, coverage
|
||||
|
||||
# Add router
|
||||
app.include_router(coverage.router, prefix="/api/coverage", tags=["coverage"])
|
||||
```
|
||||
|
||||
**Update version:**
|
||||
```python
|
||||
version="1.3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Create Buildings Cache Directory
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/rfcp/backend/data/buildings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Test
|
||||
|
||||
```bash
|
||||
# Restart
|
||||
sudo systemctl restart rfcp-backend
|
||||
|
||||
# Test buildings endpoint
|
||||
curl "https://api.rfcp.eliah.one/api/coverage/buildings?min_lat=48.45&min_lon=35.0&max_lat=48.47&max_lon=35.02"
|
||||
|
||||
# Test coverage calculation (single site)
|
||||
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sites": [{
|
||||
"lat": 48.46,
|
||||
"lon": 35.05,
|
||||
"height": 30,
|
||||
"power": 43,
|
||||
"gain": 15,
|
||||
"frequency": 1800
|
||||
}],
|
||||
"settings": {
|
||||
"radius": 2000,
|
||||
"resolution": 100,
|
||||
"use_terrain": true,
|
||||
"use_buildings": true
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] `/api/coverage/buildings` returns OSM buildings with heights
|
||||
- [ ] Buildings cached to disk (check `/opt/rfcp/backend/data/buildings/`)
|
||||
- [ ] `/api/coverage/calculate` returns coverage grid
|
||||
- [ ] Response includes `terrain_loss` and `building_loss` per point
|
||||
- [ ] Stats show `los_percentage` and building/terrain impact
|
||||
- [ ] Swagger docs show new endpoints
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Created
|
||||
|
||||
```
|
||||
app/services/
|
||||
├── buildings_service.py # NEW - OSM Overpass integration
|
||||
└── coverage_service.py # NEW - RF coverage calculation
|
||||
|
||||
app/api/routes/
|
||||
└── coverage.py # NEW - API endpoints
|
||||
|
||||
data/buildings/
|
||||
└── *.json # Cached building data per bbox
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Overpass API has rate limits (~10k requests/day) — caching critical
|
||||
- Building height estimation: `levels × 3m` or defaults by type
|
||||
- Building penetration loss: ~20dB for concrete (simplified)
|
||||
- Diffraction uses knife-edge approximation
|
||||
- Coverage calculation can be slow for large areas — consider async/background tasks later
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next: Iteration 1.4
|
||||
|
||||
- Frontend integration (replace browser calculation with API)
|
||||
- Real-time coverage updates
|
||||
- Progress indication for large calculations
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
1574
docs/devlog/back/RFCP-Iteration-1.4-Advanced-Propagation.md
Normal file
1574
docs/devlog/back/RFCP-Iteration-1.4-Advanced-Propagation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,846 @@
|
||||
# RFCP Iteration 1.5: Frontend ↔ Backend Integration
|
||||
|
||||
**Date:** January 31, 2025
|
||||
**Type:** Full-Stack Integration
|
||||
**Estimated:** 8-12 hours
|
||||
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Replace browser-based coverage calculation with backend API. Add propagation model selection UI and real-time progress indication.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
**Frontend:**
|
||||
- Coverage calculated in Web Workers (browser)
|
||||
- Settings stored in localStorage/IndexedDB
|
||||
- No connection to backend API
|
||||
- ~0.15s calculation time (simple model)
|
||||
|
||||
**Backend:**
|
||||
- Full propagation engine ready (1.4)
|
||||
- 4 presets (fast/standard/detailed/full)
|
||||
- 6 propagation models available
|
||||
- API at `https://api.rfcp.eliah.one`
|
||||
|
||||
**Gap:**
|
||||
- Frontend doesn't call backend
|
||||
- No model selection UI
|
||||
- No progress indication for slow calculations
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Site Panel │───▶│ Coverage Settings Panel │ │
|
||||
│ └─────────────┘ │ ├── Radius │ │
|
||||
│ │ ├── Resolution │ │
|
||||
│ │ ├── Preset [dropdown] │ NEW │
|
||||
│ │ └── Model toggles │ NEW │
|
||||
│ └───────────┬─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼────────────────┐ │
|
||||
│ │ Coverage Service (API client) │ │
|
||||
│ │ ├── POST /api/coverage/calculate │ │
|
||||
│ │ ├── Progress polling / SSE │ │
|
||||
│ │ └── Result caching │ │
|
||||
│ └──────────────────────────────┬────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼────────────────┐ │
|
||||
│ │ Map Visualization │ │
|
||||
│ │ ├── Heatmap layer (existing) │ │
|
||||
│ │ └── Model info overlay NEW │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Backend API │
|
||||
│ POST /api/coverage/calculate │
|
||||
│ GET /api/coverage/presets │
|
||||
│ GET /api/terrain/elevation │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### Task 1.5.1: API Client Service (2-3 hours)
|
||||
|
||||
**frontend/src/services/api.ts:**
|
||||
```typescript
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one';
|
||||
|
||||
export interface CoverageRequest {
|
||||
sites: SiteParams[];
|
||||
settings: CoverageSettings;
|
||||
}
|
||||
|
||||
export interface SiteParams {
|
||||
lat: number;
|
||||
lon: number;
|
||||
height: number;
|
||||
power: number;
|
||||
gain: number;
|
||||
frequency: number;
|
||||
azimuth?: number;
|
||||
beamwidth?: number;
|
||||
}
|
||||
|
||||
export interface CoverageSettings {
|
||||
radius: number;
|
||||
resolution: number;
|
||||
min_signal: number;
|
||||
preset?: 'fast' | 'standard' | 'detailed' | 'full';
|
||||
use_terrain?: boolean;
|
||||
use_buildings?: boolean;
|
||||
use_materials?: boolean;
|
||||
use_dominant_path?: boolean;
|
||||
use_street_canyon?: boolean;
|
||||
use_reflections?: boolean;
|
||||
}
|
||||
|
||||
export interface CoveragePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
distance: number;
|
||||
has_los: boolean;
|
||||
terrain_loss: number;
|
||||
building_loss: number;
|
||||
reflection_gain: number;
|
||||
}
|
||||
|
||||
export interface CoverageResponse {
|
||||
points: CoveragePoint[];
|
||||
count: number;
|
||||
settings: CoverageSettings;
|
||||
stats: CoverageStats;
|
||||
computation_time: number;
|
||||
models_used: string[];
|
||||
}
|
||||
|
||||
export interface CoverageStats {
|
||||
min_rsrp: number;
|
||||
max_rsrp: number;
|
||||
avg_rsrp: number;
|
||||
los_percentage: number;
|
||||
points_with_buildings: number;
|
||||
points_with_terrain_loss: number;
|
||||
points_with_reflection_gain: number;
|
||||
}
|
||||
|
||||
export interface Preset {
|
||||
description: string;
|
||||
use_terrain: boolean;
|
||||
use_buildings: boolean;
|
||||
use_materials: boolean;
|
||||
use_dominant_path: boolean;
|
||||
use_street_canyon: boolean;
|
||||
use_reflections: boolean;
|
||||
estimated_speed: string;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
async getPresets(): Promise<Record<string, Preset>> {
|
||||
const response = await fetch(`${API_BASE}/api/coverage/presets`);
|
||||
if (!response.ok) throw new Error('Failed to fetch presets');
|
||||
const data = await response.json();
|
||||
return data.presets;
|
||||
}
|
||||
|
||||
async calculateCoverage(
|
||||
request: CoverageRequest,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<CoverageResponse> {
|
||||
// Cancel previous request if running
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/coverage/calculate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Coverage calculation failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
cancelCalculation() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getElevation(lat: number, lon: number): Promise<number> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/terrain/elevation?lat=${lat}&lon=${lon}`
|
||||
);
|
||||
if (!response.ok) return 0;
|
||||
const data = await response.json();
|
||||
return data.elevation;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.2: Coverage Settings Panel Update (3-4 hours)
|
||||
|
||||
**frontend/src/components/panels/CoverageSettingsPanel.tsx:**
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Preset } from '../../services/api';
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
import { NumberInput } from '../ui/NumberInput';
|
||||
import { Toggle } from '../ui/Toggle';
|
||||
|
||||
export function CoverageSettingsPanel() {
|
||||
const { settings, updateSettings, isCalculating } = useCoverageStore();
|
||||
const [presets, setPresets] = useState<Record<string, Preset>>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Load presets on mount
|
||||
useEffect(() => {
|
||||
api.getPresets().then(setPresets).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handlePresetChange = (preset: string) => {
|
||||
if (presets[preset]) {
|
||||
updateSettings({
|
||||
preset,
|
||||
...presets[preset]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="coverage-settings-panel">
|
||||
<h3>Coverage Settings</h3>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<NumberInput
|
||||
label="Radius"
|
||||
value={settings.radius / 1000}
|
||||
onChange={(v) => updateSettings({ radius: v * 1000 })}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="km"
|
||||
tooltip="Coverage calculation radius around each site"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Resolution"
|
||||
value={settings.resolution}
|
||||
onChange={(v) => updateSettings({ resolution: v })}
|
||||
min={50}
|
||||
max={500}
|
||||
step={50}
|
||||
unit="m"
|
||||
tooltip="Grid spacing — lower = more accurate but slower"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Min Signal"
|
||||
value={settings.min_signal}
|
||||
onChange={(v) => updateSettings({ min_signal: v })}
|
||||
min={-140}
|
||||
max={-50}
|
||||
step={5}
|
||||
unit="dBm"
|
||||
tooltip="RSRP threshold — points below this are hidden"
|
||||
/>
|
||||
|
||||
{/* Propagation Model Preset */}
|
||||
<div className="setting-group">
|
||||
<label>Propagation Model</label>
|
||||
<select
|
||||
value={settings.preset || 'standard'}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
disabled={isCalculating}
|
||||
>
|
||||
{Object.entries(presets).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} — {preset.estimated_speed}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{presets[settings.preset || 'standard'] && (
|
||||
<p className="hint">
|
||||
{presets[settings.preset || 'standard'].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Toggles */}
|
||||
<div className="advanced-section">
|
||||
<button
|
||||
className="advanced-toggle"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="advanced-options">
|
||||
<Toggle
|
||||
label="Terrain (SRTM)"
|
||||
checked={settings.use_terrain}
|
||||
onChange={(v) => updateSettings({ use_terrain: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Buildings (OSM)"
|
||||
checked={settings.use_buildings}
|
||||
onChange={(v) => updateSettings({ use_buildings: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Building Materials"
|
||||
checked={settings.use_materials}
|
||||
onChange={(v) => updateSettings({ use_materials: v })}
|
||||
disabled={isCalculating || !settings.use_buildings}
|
||||
/>
|
||||
<Toggle
|
||||
label="Dominant Path Analysis"
|
||||
checked={settings.use_dominant_path}
|
||||
onChange={(v) => updateSettings({ use_dominant_path: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Street Canyon"
|
||||
checked={settings.use_street_canyon}
|
||||
onChange={(v) => updateSettings({ use_street_canyon: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Reflections"
|
||||
checked={settings.use_reflections}
|
||||
onChange={(v) => updateSettings({ use_reflections: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.3: Coverage Store Update (2-3 hours)
|
||||
|
||||
**frontend/src/store/coverage.ts:**
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
import { api, CoverageResponse, CoverageSettings, CoveragePoint } from '../services/api';
|
||||
import { useSitesStore } from './sites';
|
||||
|
||||
interface CoverageState {
|
||||
// Data
|
||||
points: CoveragePoint[];
|
||||
stats: CoverageResponse['stats'] | null;
|
||||
|
||||
// Settings
|
||||
settings: CoverageSettings;
|
||||
|
||||
// UI State
|
||||
isCalculating: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
lastCalculation: {
|
||||
time: number;
|
||||
models: string[];
|
||||
} | null;
|
||||
|
||||
// Actions
|
||||
updateSettings: (settings: Partial<CoverageSettings>) => void;
|
||||
calculateCoverage: () => Promise<void>;
|
||||
cancelCalculation: () => void;
|
||||
clearCoverage: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: CoverageSettings = {
|
||||
radius: 10000,
|
||||
resolution: 200,
|
||||
min_signal: -100,
|
||||
preset: 'standard',
|
||||
use_terrain: true,
|
||||
use_buildings: true,
|
||||
use_materials: true,
|
||||
use_dominant_path: false,
|
||||
use_street_canyon: false,
|
||||
use_reflections: false,
|
||||
};
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
points: [],
|
||||
stats: null,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isCalculating: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
lastCalculation: null,
|
||||
|
||||
updateSettings: (newSettings) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings }
|
||||
}));
|
||||
},
|
||||
|
||||
calculateCoverage: async () => {
|
||||
const { settings } = get();
|
||||
const sites = useSitesStore.getState().sites;
|
||||
|
||||
if (sites.length === 0) {
|
||||
set({ error: 'No sites to calculate coverage for' });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isCalculating: true, progress: 0, error: null });
|
||||
|
||||
try {
|
||||
// Convert sites to API format
|
||||
const apiSites = sites.flatMap(site =>
|
||||
site.sectors.map(sector => ({
|
||||
lat: site.lat,
|
||||
lon: site.lon,
|
||||
height: sector.height,
|
||||
power: 10 * Math.log10(sector.power * 1000), // W to dBm
|
||||
gain: sector.gain,
|
||||
frequency: sector.frequency,
|
||||
azimuth: sector.antennaType === 'directional' ? sector.azimuth : undefined,
|
||||
beamwidth: sector.antennaType === 'directional' ? sector.beamwidth : undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await api.calculateCoverage({
|
||||
sites: apiSites,
|
||||
settings
|
||||
});
|
||||
|
||||
set({
|
||||
points: response.points,
|
||||
stats: response.stats,
|
||||
isCalculating: false,
|
||||
progress: 100,
|
||||
lastCalculation: {
|
||||
time: response.computation_time,
|
||||
models: response.models_used
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
set({ isCalculating: false, progress: 0 });
|
||||
} else {
|
||||
set({
|
||||
isCalculating: false,
|
||||
error: error instanceof Error ? error.message : 'Calculation failed',
|
||||
progress: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancelCalculation: () => {
|
||||
api.cancelCalculation();
|
||||
set({ isCalculating: false, progress: 0 });
|
||||
},
|
||||
|
||||
clearCoverage: () => {
|
||||
set({ points: [], stats: null, lastCalculation: null });
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.4: Calculate Button & Progress (2-3 hours)
|
||||
|
||||
**frontend/src/components/CoverageButton.tsx:**
|
||||
```typescript
|
||||
import { useCoverageStore } from '../store/coverage';
|
||||
import { useSitesStore } from '../store/sites';
|
||||
|
||||
export function CoverageButton() {
|
||||
const {
|
||||
isCalculating,
|
||||
progress,
|
||||
calculateCoverage,
|
||||
cancelCalculation,
|
||||
lastCalculation
|
||||
} = useCoverageStore();
|
||||
const sitesCount = useSitesStore((s) => s.sites.length);
|
||||
|
||||
if (isCalculating) {
|
||||
return (
|
||||
<div className="coverage-button calculating">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={cancelCalculation} className="cancel-btn">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="coverage-button">
|
||||
<button
|
||||
onClick={calculateCoverage}
|
||||
disabled={sitesCount === 0}
|
||||
className="calculate-btn"
|
||||
>
|
||||
Calculate Coverage
|
||||
</button>
|
||||
{lastCalculation && (
|
||||
<span className="last-calc-info">
|
||||
{lastCalculation.time.toFixed(1)}s • {lastCalculation.models.length} models
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**CSS (coverage-button.css):**
|
||||
```css
|
||||
.coverage-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calculate-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.calculate-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.calculate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.coverage-button.calculating {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.last-calc-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.5: Update Heatmap to Use API Points (2-3 hours)
|
||||
|
||||
**frontend/src/components/map/CoverageLayer.tsx:**
|
||||
```typescript
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
import { createHeatmapTiles } from '../../lib/heatmap';
|
||||
|
||||
export function CoverageLayer() {
|
||||
const map = useMap();
|
||||
const { points, settings } = useCoverageStore();
|
||||
|
||||
// Convert API points to heatmap format
|
||||
const heatmapData = useMemo(() => {
|
||||
if (!points.length) return [];
|
||||
|
||||
return points.map(p => ({
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
value: p.rsrp,
|
||||
// Additional data for tooltips
|
||||
hasLos: p.has_los,
|
||||
terrainLoss: p.terrain_loss,
|
||||
buildingLoss: p.building_loss,
|
||||
reflectionGain: p.reflection_gain,
|
||||
}));
|
||||
}, [points]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!heatmapData.length) return;
|
||||
|
||||
const tileLayer = createHeatmapTiles(heatmapData, {
|
||||
minSignal: settings.min_signal,
|
||||
maxSignal: -50,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
tileLayer.addTo(map);
|
||||
|
||||
return () => {
|
||||
map.removeLayer(tileLayer);
|
||||
};
|
||||
}, [map, heatmapData, settings.min_signal]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.6: Stats Panel Update (1-2 hours)
|
||||
|
||||
**frontend/src/components/panels/StatsPanel.tsx:**
|
||||
```typescript
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
|
||||
export function StatsPanel() {
|
||||
const { stats, lastCalculation, points } = useCoverageStore();
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stats-panel empty">
|
||||
<p>Calculate coverage to see statistics</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-panel">
|
||||
<h3>Coverage Statistics</h3>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat">
|
||||
<span className="label">Points</span>
|
||||
<span className="value">{points.length.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Min RSRP</span>
|
||||
<span className="value">{stats.min_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Max RSRP</span>
|
||||
<span className="value">{stats.max_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Avg RSRP</span>
|
||||
<span className="value">{stats.avg_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Line of Sight</span>
|
||||
<span className="value">{stats.los_percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Terrain Affected</span>
|
||||
<span className="value">{stats.points_with_terrain_loss}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Building Affected</span>
|
||||
<span className="value">{stats.points_with_buildings}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">With Reflections</span>
|
||||
<span className="value">{stats.points_with_reflection_gain}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCalculation && (
|
||||
<div className="calc-info">
|
||||
<p>
|
||||
Calculated in <strong>{lastCalculation.time.toFixed(1)}s</strong>
|
||||
</p>
|
||||
<p className="models">
|
||||
Models: {lastCalculation.models.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.7: Environment Configuration (1 hour)
|
||||
|
||||
**frontend/.env.development:**
|
||||
```env
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
```
|
||||
|
||||
**frontend/.env.production:**
|
||||
```env
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
```
|
||||
|
||||
**frontend/.env.local (for local backend):**
|
||||
```env
|
||||
VITE_API_URL=http://localhost:8888
|
||||
```
|
||||
|
||||
**frontend/vite.config.ts update:**
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
// ... existing config
|
||||
define: {
|
||||
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# 1. Start backend (if not running)
|
||||
sudo systemctl start rfcp-backend
|
||||
|
||||
# 2. Start frontend dev
|
||||
cd /opt/rfcp/frontend
|
||||
npm run dev
|
||||
|
||||
# 3. Test in browser
|
||||
# - Create a site
|
||||
# - Select "Fast" preset
|
||||
# - Click Calculate Coverage
|
||||
# - Verify heatmap appears
|
||||
# - Check stats panel shows API data
|
||||
|
||||
# 4. Test presets
|
||||
# - Switch to "Standard" - should take ~30s
|
||||
# - Switch to "Full" (small radius!) - should take ~2min
|
||||
# - Verify models_used changes
|
||||
|
||||
# 5. Test cancel
|
||||
# - Start "Full" calculation
|
||||
# - Click Cancel
|
||||
# - Verify it stops
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Coverage calculated via backend API (not browser)
|
||||
- [ ] Preset dropdown shows 4 options with descriptions
|
||||
- [ ] Advanced toggles work independently
|
||||
- [ ] Progress indication during calculation
|
||||
- [ ] Cancel button stops calculation
|
||||
- [ ] Stats panel shows API response data
|
||||
- [ ] `computation_time` and `models_used` displayed
|
||||
- [ ] Heatmap renders API points correctly
|
||||
- [ ] Error handling for API failures
|
||||
- [ ] Works with multiple sites/sectors
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Changed
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── services/
|
||||
│ └── api.ts # NEW - API client
|
||||
├── store/
|
||||
│ └── coverage.ts # MODIFIED - API integration
|
||||
├── components/
|
||||
│ ├── panels/
|
||||
│ │ ├── CoverageSettingsPanel.tsx # MODIFIED - preset UI
|
||||
│ │ └── StatsPanel.tsx # MODIFIED - API stats
|
||||
│ ├── map/
|
||||
│ │ └── CoverageLayer.tsx # MODIFIED - use API points
|
||||
│ └── CoverageButton.tsx # NEW - calculate + progress
|
||||
├── .env.development # NEW
|
||||
├── .env.production # NEW
|
||||
└── vite.config.ts # MODIFIED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Remove or disable old Web Worker calculation after integration confirmed
|
||||
- Keep localStorage for sites/settings backup (offline fallback)
|
||||
- Consider WebSocket for real progress updates (future enhancement)
|
||||
- API timeout should be generous (5+ minutes for "full" preset)
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next
|
||||
|
||||
After 1.5 complete:
|
||||
- **1.4.1** — Enhanced Environment (R-tree, water, vegetation)
|
||||
- **1.4.2** — Extra Factors (weather, indoor)
|
||||
- **2.1** — Desktop Installer
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
229
docs/devlog/back/RFCP-Iteration-1.5.1-Fixes-Boundaries.md
Normal file
229
docs/devlog/back/RFCP-Iteration-1.5.1-Fixes-Boundaries.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# RFCP Iteration 1.5.1: Fixes & Boundaries
|
||||
|
||||
**Date:** January 31, 2025
|
||||
**Type:** Bugfix & Polish
|
||||
**Estimated:** 2-3 hours
|
||||
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Fix Fresnel endpoint 500 error, restore coverage boundary visualization, minor polish.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Issues to Fix
|
||||
|
||||
### 1. Fresnel Endpoint 500 Error
|
||||
|
||||
**Symptom:**
|
||||
```bash
|
||||
curl "/api/terrain/fresnel?tx_lat=48.46&tx_lon=35.05&tx_height=30&rx_lat=48.47&rx_lon=35.06&rx_height=1.5&frequency=1800"
|
||||
# Returns: 500 Internal Server Error
|
||||
```
|
||||
|
||||
**Location:** `backend/app/api/routes/terrain.py`
|
||||
|
||||
**Likely cause:** Missing `rx_height` default or async issue in `los_service.check_fresnel_clearance()`
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
@router.get("/fresnel")
|
||||
async def check_fresnel(
|
||||
tx_lat: float,
|
||||
tx_lon: float,
|
||||
tx_height: float,
|
||||
rx_lat: float,
|
||||
rx_lon: float,
|
||||
rx_height: float = 1.5, # Default receiver height
|
||||
frequency: float = 1800 # Default frequency MHz
|
||||
):
|
||||
try:
|
||||
result = await los_service.check_fresnel_clearance(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Fresnel calculation error: {str(e)}")
|
||||
```
|
||||
|
||||
**Debug:** Check backend logs for actual error:
|
||||
```bash
|
||||
journalctl -u rfcp-backend -n 50 | grep -i error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Coverage Boundary Not Showing
|
||||
|
||||
**Symptom:** Boundary contour line (-100 dBm) not visible on map after API integration
|
||||
|
||||
**Location:** `frontend/src/components/map/CoverageBoundary.tsx`
|
||||
|
||||
**Likely cause:** Component expecting old data format, not new API response
|
||||
|
||||
**Check:**
|
||||
1. Is `CoverageBoundary` still mounted in map?
|
||||
2. Does it receive `points` from coverage store?
|
||||
3. Is boundary calculation using correct field (`rsrp` vs old field name)?
|
||||
|
||||
**Fix approach:**
|
||||
```typescript
|
||||
// CoverageBoundary.tsx
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
|
||||
export function CoverageBoundary() {
|
||||
const { points, settings } = useCoverageStore();
|
||||
|
||||
// Generate boundary from API points
|
||||
const boundaryPoints = useMemo(() => {
|
||||
if (!points.length) return [];
|
||||
|
||||
// Filter points near threshold
|
||||
const threshold = settings.min_signal; // -100 dBm
|
||||
const tolerance = 5; // ±5 dBm
|
||||
|
||||
return points
|
||||
.filter(p => Math.abs(p.rsrp - threshold) < tolerance)
|
||||
.map(p => [p.lat, p.lon] as [number, number]);
|
||||
}, [points, settings.min_signal]);
|
||||
|
||||
// ... rest of boundary rendering
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative:** Use convex hull or marching squares for proper contour:
|
||||
```typescript
|
||||
import { concaveman } from 'concaveman';
|
||||
|
||||
const boundaryPolygon = useMemo(() => {
|
||||
const edgePoints = points
|
||||
.filter(p => p.rsrp >= settings.min_signal && p.rsrp < settings.min_signal + 10)
|
||||
.map(p => [p.lon, p.lat]);
|
||||
|
||||
if (edgePoints.length < 3) return null;
|
||||
|
||||
return concaveman(edgePoints, 2); // concavity factor
|
||||
}, [points, settings.min_signal]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Minor Polish
|
||||
|
||||
**a) Stats panel — show models used:**
|
||||
```typescript
|
||||
// StatsPanel.tsx
|
||||
{lastCalculation && (
|
||||
<div className="models-used">
|
||||
<span className="label">Models:</span>
|
||||
<div className="model-tags">
|
||||
{lastCalculation.models.map(m => (
|
||||
<span key={m} className="model-tag">{m}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**b) Loading state during long calculations:**
|
||||
```typescript
|
||||
// Show elapsed time during calculation
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCalculating) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - start) / 1000));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isCalculating]);
|
||||
|
||||
// In render:
|
||||
{isCalculating && (
|
||||
<div className="calculating-status">
|
||||
Calculating... {elapsed}s
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**c) Error toast for API failures:**
|
||||
```typescript
|
||||
// In coverage store calculateCoverage()
|
||||
catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Calculation failed';
|
||||
toast.error(message); // If using toast library
|
||||
set({ error: message, isCalculating: false });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
- [ ] Fix Fresnel endpoint (backend)
|
||||
- [ ] Debug and check logs for actual error
|
||||
- [ ] Restore CoverageBoundary with API points
|
||||
- [ ] Test boundary renders correctly
|
||||
- [ ] Add elapsed time counter during calculation
|
||||
- [ ] Add model tags to stats panel
|
||||
- [ ] Test all presets still work
|
||||
- [ ] Run integration test — should be 21/21
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# 1. Test Fresnel fix
|
||||
curl "https://api.rfcp.eliah.one/api/terrain/fresnel?tx_lat=48.46&tx_lon=35.05&tx_height=30&rx_lat=48.47&rx_lon=35.06&rx_height=1.5&frequency=1800"
|
||||
# Should return: {"clearance_percent": ..., "has_adequate_clearance": ...}
|
||||
|
||||
# 2. Run integration test
|
||||
./rfcp-integration-test.sh
|
||||
# Should be 21/21
|
||||
|
||||
# 3. Visual test
|
||||
# - Calculate coverage
|
||||
# - Verify boundary line appears at -100 dBm edge
|
||||
# - Verify elapsed time shows during calculation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files to Modify
|
||||
|
||||
```
|
||||
backend/app/
|
||||
├── api/routes/terrain.py # Fresnel fix
|
||||
└── services/los_service.py # Check fresnel method
|
||||
|
||||
frontend/src/
|
||||
├── components/
|
||||
│ ├── map/CoverageBoundary.tsx # Fix boundary rendering
|
||||
│ └── panels/
|
||||
│ ├── CoverageStats.tsx # Add model tags
|
||||
│ └── StatsPanel.tsx # Elapsed time
|
||||
└── store/coverage.ts # Error handling
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Boundary can use simple edge detection or proper contour algorithm
|
||||
- `concaveman` is lightweight (~2KB) for concave hull
|
||||
- Elapsed time helps user know calculation is progressing
|
||||
|
||||
---
|
||||
|
||||
**Quick iteration — should be fast** 🚀
|
||||
862
docs/devlog/back/RFCP-Iteration-1.6-Enhanced-Environment.md
Normal file
862
docs/devlog/back/RFCP-Iteration-1.6-Enhanced-Environment.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# RFCP Iteration 1.6: Enhanced Environment + Bugfixes
|
||||
|
||||
**Date:** January 31, 2025
|
||||
**Type:** Backend Enhancement + Bugfixes
|
||||
**Estimated:** 8-12 hours
|
||||
**Location:** `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Fix critical bugs from 1.5, add spatial indexing for performance, implement water/ground reflections and vegetation loss.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bugfixes (Priority)
|
||||
|
||||
### Bug 1: OSM `building:levels` Parsing Error
|
||||
|
||||
**Error:**
|
||||
```
|
||||
ValueError: invalid literal for int() with base 10: '1а'
|
||||
```
|
||||
|
||||
**Cause:** OSM data contains non-numeric levels like `"1а"`, `"2-3"`, `"5+"`, etc.
|
||||
|
||||
**Location:** `app/services/buildings_service.py` line ~160
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
# Add to BuildingsService class
|
||||
def _safe_int(self, value) -> Optional[int]:
|
||||
"""Safely parse int from OSM tag (handles '1а', '2-3', '5+', etc.)"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
import re
|
||||
match = re.search(r'\d+', str(value))
|
||||
if match:
|
||||
return int(match.group())
|
||||
return None
|
||||
|
||||
# Replace line ~160
|
||||
# OLD:
|
||||
levels=int(tags.get("building:levels", 0)) or None,
|
||||
# NEW:
|
||||
levels=self._safe_int(tags.get("building:levels")),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug 2: Height Parsing Error
|
||||
|
||||
**Similar issue with `height` tag:**
|
||||
```python
|
||||
# OSM can have: "10 m", "10m", "10.5", "~12"
|
||||
|
||||
def _safe_float(self, value) -> Optional[float]:
|
||||
"""Safely parse float from OSM tag"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
# Remove common suffixes
|
||||
cleaned = str(value).lower().replace('m', '').replace('~', '').strip()
|
||||
return float(cleaned)
|
||||
except (ValueError, TypeError):
|
||||
import re
|
||||
match = re.search(r'[\d.]+', str(value))
|
||||
if match:
|
||||
return float(match.group())
|
||||
return None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug 3: Request Timeout / Queue Blocking
|
||||
|
||||
**Issue:** Long calculations block other requests
|
||||
|
||||
**Fix:** Add timeout to coverage calculation
|
||||
```python
|
||||
# app/api/routes/coverage.py
|
||||
import asyncio
|
||||
|
||||
@router.post("/calculate")
|
||||
async def calculate_coverage(request: CoverageRequest) -> CoverageResponse:
|
||||
try:
|
||||
# Add timeout (5 minutes max)
|
||||
result = await asyncio.wait_for(
|
||||
coverage_service.calculate_coverage(...),
|
||||
timeout=300.0
|
||||
)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(408, "Calculation timeout - try smaller radius or lower resolution")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Enhancement Tasks
|
||||
|
||||
### Task 1: Spatial Indexing with R-tree (3-4 hours)
|
||||
|
||||
**Install:**
|
||||
```bash
|
||||
pip install Rtree
|
||||
```
|
||||
|
||||
**app/services/spatial_index.py:**
|
||||
```python
|
||||
from rtree import index
|
||||
from typing import List, Tuple, Optional
|
||||
from app.services.buildings_service import Building
|
||||
|
||||
class SpatialIndex:
|
||||
"""R-tree spatial index for fast building lookups"""
|
||||
|
||||
def __init__(self):
|
||||
self._index: Optional[index.Index] = None
|
||||
self._buildings: dict[int, Building] = {}
|
||||
self._bounds: Optional[Tuple[float, float, float, float]] = None
|
||||
|
||||
def build(self, buildings: List[Building]):
|
||||
"""Build spatial index from buildings list"""
|
||||
self._index = index.Index()
|
||||
self._buildings = {}
|
||||
|
||||
for i, building in enumerate(buildings):
|
||||
# Get bounding box of building
|
||||
lons = [p[0] for p in building.geometry]
|
||||
lats = [p[1] for p in building.geometry]
|
||||
|
||||
bbox = (min(lons), min(lats), max(lons), max(lats))
|
||||
|
||||
self._index.insert(i, bbox)
|
||||
self._buildings[i] = building
|
||||
|
||||
if buildings:
|
||||
all_lons = [p[0] for b in buildings for p in b.geometry]
|
||||
all_lats = [p[1] for b in buildings for p in b.geometry]
|
||||
self._bounds = (min(all_lons), min(all_lats), max(all_lons), max(all_lats))
|
||||
|
||||
def query_point(self, lat: float, lon: float, buffer: float = 0.001) -> List[Building]:
|
||||
"""Find buildings near a point"""
|
||||
if not self._index:
|
||||
return []
|
||||
|
||||
bbox = (lon - buffer, lat - buffer, lon + buffer, lat + buffer)
|
||||
indices = list(self._index.intersection(bbox))
|
||||
|
||||
return [self._buildings[i] for i in indices]
|
||||
|
||||
def query_line(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float,
|
||||
buffer: float = 0.001
|
||||
) -> List[Building]:
|
||||
"""Find buildings along a line (for LoS checks)"""
|
||||
if not self._index:
|
||||
return []
|
||||
|
||||
# Bounding box of line + buffer
|
||||
min_lon = min(lon1, lon2) - buffer
|
||||
max_lon = max(lon1, lon2) + buffer
|
||||
min_lat = min(lat1, lat2) - buffer
|
||||
max_lat = max(lat1, lat2) + buffer
|
||||
|
||||
bbox = (min_lon, min_lat, max_lon, max_lat)
|
||||
indices = list(self._index.intersection(bbox))
|
||||
|
||||
return [self._buildings[i] for i in indices]
|
||||
|
||||
def query_bbox(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
max_lat: float, max_lon: float
|
||||
) -> List[Building]:
|
||||
"""Find all buildings in bounding box"""
|
||||
if not self._index:
|
||||
return []
|
||||
|
||||
bbox = (min_lon, min_lat, max_lon, max_lat)
|
||||
indices = list(self._index.intersection(bbox))
|
||||
|
||||
return [self._buildings[i] for i in indices]
|
||||
|
||||
|
||||
# Global instance with caching
|
||||
_spatial_indices: dict[str, SpatialIndex] = {}
|
||||
|
||||
def get_spatial_index(cache_key: str, buildings: List[Building]) -> SpatialIndex:
|
||||
"""Get or create spatial index for buildings"""
|
||||
if cache_key not in _spatial_indices:
|
||||
idx = SpatialIndex()
|
||||
idx.build(buildings)
|
||||
_spatial_indices[cache_key] = idx
|
||||
|
||||
# Limit cache size
|
||||
if len(_spatial_indices) > 20:
|
||||
oldest = next(iter(_spatial_indices))
|
||||
del _spatial_indices[oldest]
|
||||
|
||||
return _spatial_indices[cache_key]
|
||||
```
|
||||
|
||||
**Update coverage_service.py:**
|
||||
```python
|
||||
from app.services.spatial_index import get_spatial_index
|
||||
|
||||
# In calculate_coverage():
|
||||
if settings.use_buildings and buildings:
|
||||
# Build spatial index once
|
||||
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
|
||||
spatial_idx = get_spatial_index(cache_key, buildings)
|
||||
|
||||
# In _calculate_point():
|
||||
# OLD: for building in buildings:
|
||||
# NEW:
|
||||
nearby_buildings = spatial_idx.query_point(lat, lon)
|
||||
for building in nearby_buildings:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Water Reflection (2-3 hours)
|
||||
|
||||
**app/services/water_service.py:**
|
||||
```python
|
||||
import httpx
|
||||
from typing import List, Tuple, Optional
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
class WaterBody(BaseModel):
|
||||
"""Water body from OSM"""
|
||||
id: int
|
||||
geometry: List[Tuple[float, float]] # [(lon, lat), ...]
|
||||
water_type: str # river, lake, pond, reservoir
|
||||
name: Optional[str] = None
|
||||
|
||||
class WaterService:
|
||||
"""OSM water bodies for reflection calculations"""
|
||||
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Reflection coefficients by water type
|
||||
REFLECTION_COEFF = {
|
||||
"lake": 0.8,
|
||||
"reservoir": 0.8,
|
||||
"river": 0.7,
|
||||
"pond": 0.75,
|
||||
"water": 0.7, # generic
|
||||
}
|
||||
|
||||
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/water"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
self._cache: dict[str, List[WaterBody]] = {}
|
||||
|
||||
async def fetch_water_bodies(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
max_lat: float, max_lon: float
|
||||
) -> List[WaterBody]:
|
||||
"""Fetch water bodies in bounding box"""
|
||||
|
||||
cache_key = f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}"
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
cache_file = self.cache_dir / f"{cache_key}.json"
|
||||
if cache_file.exists():
|
||||
with open(cache_file) as f:
|
||||
data = json.load(f)
|
||||
bodies = [WaterBody(**w) for w in data]
|
||||
self._cache[cache_key] = bodies
|
||||
return bodies
|
||||
|
||||
query = f"""
|
||||
[out:json][timeout:30];
|
||||
(
|
||||
way["natural"="water"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
relation["natural"="water"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
way["waterway"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
);
|
||||
out body;
|
||||
>;
|
||||
out skel qt;
|
||||
"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(self.OVERPASS_URL, data={"data": query})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
print(f"Water fetch error: {e}")
|
||||
return []
|
||||
|
||||
bodies = self._parse_response(data)
|
||||
|
||||
# Cache
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump([w.model_dump() for w in bodies], f)
|
||||
self._cache[cache_key] = bodies
|
||||
|
||||
return bodies
|
||||
|
||||
def _parse_response(self, data: dict) -> List[WaterBody]:
|
||||
"""Parse Overpass response"""
|
||||
nodes = {}
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] == "node":
|
||||
nodes[element["id"]] = (element["lon"], element["lat"])
|
||||
|
||||
bodies = []
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] != "way":
|
||||
continue
|
||||
|
||||
tags = element.get("tags", {})
|
||||
|
||||
# Determine water type
|
||||
water_type = tags.get("water", tags.get("waterway", tags.get("natural", "water")))
|
||||
|
||||
geometry = []
|
||||
for node_id in element.get("nodes", []):
|
||||
if node_id in nodes:
|
||||
geometry.append(nodes[node_id])
|
||||
|
||||
if len(geometry) < 3:
|
||||
continue
|
||||
|
||||
bodies.append(WaterBody(
|
||||
id=element["id"],
|
||||
geometry=geometry,
|
||||
water_type=water_type,
|
||||
name=tags.get("name")
|
||||
))
|
||||
|
||||
return bodies
|
||||
|
||||
def get_reflection_coefficient(self, water_type: str) -> float:
|
||||
"""Get reflection coefficient for water type"""
|
||||
return self.REFLECTION_COEFF.get(water_type, 0.7)
|
||||
|
||||
def point_over_water(self, lat: float, lon: float, water_bodies: List[WaterBody]) -> Optional[WaterBody]:
|
||||
"""Check if point is over water"""
|
||||
for body in water_bodies:
|
||||
if self._point_in_polygon(lat, lon, body.geometry):
|
||||
return body
|
||||
return None
|
||||
|
||||
def _point_in_polygon(self, lat: float, lon: float, polygon: List[Tuple[float, float]]) -> bool:
|
||||
"""Ray casting algorithm"""
|
||||
n = len(polygon)
|
||||
inside = False
|
||||
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = polygon[i]
|
||||
xj, yj = polygon[j]
|
||||
|
||||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
|
||||
return inside
|
||||
|
||||
|
||||
water_service = WaterService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Vegetation Loss (2-3 hours)
|
||||
|
||||
**app/services/vegetation_service.py:**
|
||||
```python
|
||||
import httpx
|
||||
from typing import List, Tuple, Optional
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
class VegetationArea(BaseModel):
|
||||
"""Vegetation area from OSM"""
|
||||
id: int
|
||||
geometry: List[Tuple[float, float]]
|
||||
vegetation_type: str # forest, wood, scrub, orchard
|
||||
density: str # dense, sparse, mixed
|
||||
|
||||
class VegetationService:
|
||||
"""OSM vegetation for signal attenuation"""
|
||||
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Attenuation dB per 100 meters
|
||||
ATTENUATION_DB_PER_100M = {
|
||||
"forest": 8.0, # Dense forest
|
||||
"wood": 6.0, # Woods
|
||||
"tree_row": 2.0, # Single row of trees
|
||||
"scrub": 3.0, # Bushes
|
||||
"orchard": 2.0, # Fruit trees (spaced)
|
||||
"vineyard": 1.0, # Low vegetation
|
||||
"meadow": 0.5, # Grass only
|
||||
}
|
||||
|
||||
# Seasonal factor (summer = full foliage)
|
||||
SEASONAL_FACTOR = {
|
||||
"summer": 1.0,
|
||||
"winter": 0.3, # Deciduous trees lose leaves
|
||||
"spring": 0.6,
|
||||
"autumn": 0.7,
|
||||
}
|
||||
|
||||
def __init__(self, cache_dir: str = "/opt/rfcp/backend/data/vegetation"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True, parents=True)
|
||||
self._cache: dict[str, List[VegetationArea]] = {}
|
||||
|
||||
async def fetch_vegetation(
|
||||
self,
|
||||
min_lat: float, min_lon: float,
|
||||
max_lat: float, max_lon: float
|
||||
) -> List[VegetationArea]:
|
||||
"""Fetch vegetation areas in bounding box"""
|
||||
|
||||
cache_key = f"{min_lat:.2f}_{min_lon:.2f}_{max_lat:.2f}_{max_lon:.2f}"
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
cache_file = self.cache_dir / f"{cache_key}.json"
|
||||
if cache_file.exists():
|
||||
with open(cache_file) as f:
|
||||
data = json.load(f)
|
||||
areas = [VegetationArea(**v) for v in data]
|
||||
self._cache[cache_key] = areas
|
||||
return areas
|
||||
|
||||
query = f"""
|
||||
[out:json][timeout:30];
|
||||
(
|
||||
way["landuse"="forest"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
way["natural"="wood"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
way["landuse"="orchard"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
way["natural"="scrub"]({min_lat},{min_lon},{max_lat},{max_lon});
|
||||
);
|
||||
out body;
|
||||
>;
|
||||
out skel qt;
|
||||
"""
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(self.OVERPASS_URL, data={"data": query})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
print(f"Vegetation fetch error: {e}")
|
||||
return []
|
||||
|
||||
areas = self._parse_response(data)
|
||||
|
||||
# Cache
|
||||
with open(cache_file, 'w') as f:
|
||||
json.dump([v.model_dump() for v in areas], f)
|
||||
self._cache[cache_key] = areas
|
||||
|
||||
return areas
|
||||
|
||||
def _parse_response(self, data: dict) -> List[VegetationArea]:
|
||||
"""Parse Overpass response"""
|
||||
nodes = {}
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] == "node":
|
||||
nodes[element["id"]] = (element["lon"], element["lat"])
|
||||
|
||||
areas = []
|
||||
for element in data.get("elements", []):
|
||||
if element["type"] != "way":
|
||||
continue
|
||||
|
||||
tags = element.get("tags", {})
|
||||
|
||||
veg_type = tags.get("landuse", tags.get("natural", "forest"))
|
||||
|
||||
geometry = []
|
||||
for node_id in element.get("nodes", []):
|
||||
if node_id in nodes:
|
||||
geometry.append(nodes[node_id])
|
||||
|
||||
if len(geometry) < 3:
|
||||
continue
|
||||
|
||||
# Determine density
|
||||
leaf_type = tags.get("leaf_type", "mixed")
|
||||
density = "dense" if leaf_type == "needleleaved" else "mixed"
|
||||
|
||||
areas.append(VegetationArea(
|
||||
id=element["id"],
|
||||
geometry=geometry,
|
||||
vegetation_type=veg_type,
|
||||
density=density
|
||||
))
|
||||
|
||||
return areas
|
||||
|
||||
def calculate_vegetation_loss(
|
||||
self,
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float,
|
||||
vegetation_areas: List[VegetationArea],
|
||||
season: str = "summer"
|
||||
) -> float:
|
||||
"""
|
||||
Calculate signal loss through vegetation along path
|
||||
|
||||
Returns loss in dB
|
||||
"""
|
||||
from app.services.terrain_service import TerrainService
|
||||
|
||||
total_loss = 0.0
|
||||
path_length = TerrainService.haversine_distance(lat1, lon1, lat2, lon2)
|
||||
|
||||
if path_length < 1:
|
||||
return 0.0
|
||||
|
||||
# Sample points along path
|
||||
num_samples = max(10, int(path_length / 50)) # Every 50m
|
||||
|
||||
vegetation_distance = 0.0
|
||||
current_veg_type = None
|
||||
|
||||
for i in range(num_samples):
|
||||
t = i / num_samples
|
||||
lat = lat1 + t * (lat2 - lat1)
|
||||
lon = lon1 + t * (lon2 - lon1)
|
||||
|
||||
# Check if point is in vegetation
|
||||
veg = self._point_in_vegetation(lat, lon, vegetation_areas)
|
||||
|
||||
if veg:
|
||||
segment_length = path_length / num_samples
|
||||
vegetation_distance += segment_length
|
||||
current_veg_type = veg.vegetation_type
|
||||
|
||||
if vegetation_distance > 0 and current_veg_type:
|
||||
# Calculate loss
|
||||
attenuation = self.ATTENUATION_DB_PER_100M.get(current_veg_type, 4.0)
|
||||
seasonal = self.SEASONAL_FACTOR.get(season, 1.0)
|
||||
|
||||
total_loss = (vegetation_distance / 100) * attenuation * seasonal
|
||||
|
||||
return min(total_loss, 40.0) # Cap at 40 dB
|
||||
|
||||
def _point_in_vegetation(
|
||||
self,
|
||||
lat: float, lon: float,
|
||||
areas: List[VegetationArea]
|
||||
) -> Optional[VegetationArea]:
|
||||
"""Check if point is in vegetation area"""
|
||||
for area in areas:
|
||||
if self._point_in_polygon(lat, lon, area.geometry):
|
||||
return area
|
||||
return None
|
||||
|
||||
def _point_in_polygon(self, lat: float, lon: float, polygon: List[Tuple[float, float]]) -> bool:
|
||||
"""Ray casting algorithm"""
|
||||
n = len(polygon)
|
||||
inside = False
|
||||
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = polygon[i]
|
||||
xj, yj = polygon[j]
|
||||
|
||||
if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi):
|
||||
inside = not inside
|
||||
j = i
|
||||
|
||||
return inside
|
||||
|
||||
|
||||
vegetation_service = VegetationService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Ground Reflection (1-2 hours)
|
||||
|
||||
**Update app/services/reflection_service.py:**
|
||||
|
||||
```python
|
||||
# Add to ReflectionService class
|
||||
|
||||
# Ground types and reflection coefficients
|
||||
GROUND_REFLECTION = {
|
||||
"urban": 0.3, # Asphalt, concrete
|
||||
"suburban": 0.4, # Mixed
|
||||
"rural": 0.5, # Grass, soil
|
||||
"water": 0.8, # Lakes, rivers
|
||||
"desert": 0.6, # Sand
|
||||
}
|
||||
|
||||
def calculate_ground_reflection(
|
||||
self,
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
ground_type: str = "rural",
|
||||
water_body: Optional[WaterBody] = None
|
||||
) -> ReflectionPath:
|
||||
"""Calculate ground/water reflection path"""
|
||||
|
||||
from app.services.terrain_service import TerrainService
|
||||
|
||||
# Direct distance
|
||||
direct_dist = TerrainService.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
|
||||
# Reflection point (simplified - midpoint)
|
||||
mid_lat = (tx_lat + rx_lat) / 2
|
||||
mid_lon = (tx_lon + rx_lon) / 2
|
||||
|
||||
# Get ground elevation at reflection point
|
||||
ground_elev = await self.terrain.get_elevation(mid_lat, mid_lon)
|
||||
|
||||
# Path lengths (TX -> ground -> RX)
|
||||
d1 = direct_dist / 2
|
||||
h1 = tx_height
|
||||
h2 = rx_height
|
||||
|
||||
# Actual path length
|
||||
path1 = np.sqrt(d1**2 + h1**2)
|
||||
path2 = np.sqrt(d1**2 + h2**2)
|
||||
total_path = path1 + path2
|
||||
|
||||
# Reflection coefficient
|
||||
if water_body:
|
||||
coeff = self.GROUND_REFLECTION["water"]
|
||||
else:
|
||||
coeff = self.GROUND_REFLECTION.get(ground_type, 0.4)
|
||||
|
||||
# Path loss
|
||||
path_loss = self._free_space_loss(total_path, frequency_mhz)
|
||||
reflection_loss = -10 * np.log10(coeff)
|
||||
|
||||
total_loss = path_loss + reflection_loss
|
||||
|
||||
return ReflectionPath(
|
||||
points=[(tx_lat, tx_lon), (mid_lat, mid_lon), (rx_lat, rx_lon)],
|
||||
total_distance=total_path,
|
||||
total_loss=total_loss,
|
||||
reflection_count=1,
|
||||
materials=["ground" if not water_body else "water"]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integrate into Coverage Service (2 hours)
|
||||
|
||||
**Update app/services/coverage_service.py:**
|
||||
|
||||
```python
|
||||
# Add imports
|
||||
from app.services.spatial_index import get_spatial_index
|
||||
from app.services.water_service import water_service
|
||||
from app.services.vegetation_service import vegetation_service
|
||||
|
||||
# Update CoverageSettings
|
||||
class CoverageSettings(BaseModel):
|
||||
# ... existing fields ...
|
||||
use_water_reflection: bool = False
|
||||
use_vegetation: bool = False
|
||||
season: str = "summer" # For vegetation loss
|
||||
|
||||
# Update calculate_coverage()
|
||||
async def calculate_coverage(self, site: SiteParams, settings: CoverageSettings):
|
||||
# ... existing setup ...
|
||||
|
||||
# Fetch additional data if enabled
|
||||
water_bodies = []
|
||||
vegetation_areas = []
|
||||
|
||||
if settings.use_water_reflection:
|
||||
water_bodies = await water_service.fetch_water_bodies(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
|
||||
if settings.use_vegetation:
|
||||
vegetation_areas = await vegetation_service.fetch_vegetation(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
|
||||
# Build spatial index for buildings
|
||||
spatial_idx = None
|
||||
if settings.use_buildings and buildings:
|
||||
cache_key = f"{min_lat:.3f},{min_lon:.3f},{max_lat:.3f},{max_lon:.3f}"
|
||||
spatial_idx = get_spatial_index(cache_key, buildings)
|
||||
|
||||
# Calculate points...
|
||||
|
||||
# Update _calculate_point()
|
||||
async def _calculate_point(self, ...):
|
||||
# ... existing code ...
|
||||
|
||||
# Vegetation loss
|
||||
vegetation_loss = 0.0
|
||||
if settings.use_vegetation and vegetation_areas:
|
||||
vegetation_loss = vegetation_service.calculate_vegetation_loss(
|
||||
site.lat, site.lon, lat, lon,
|
||||
vegetation_areas, settings.season
|
||||
)
|
||||
|
||||
# Water reflection boost
|
||||
water_reflection_gain = 0.0
|
||||
if settings.use_water_reflection and water_bodies:
|
||||
water = water_service.point_over_water(lat, lon, water_bodies)
|
||||
if water:
|
||||
# Better reflection over water
|
||||
water_reflection_gain = 3.0 # ~3dB boost
|
||||
|
||||
# Final RSRP
|
||||
rsrp = (site.power + site.gain - path_loss - antenna_loss
|
||||
- terrain_loss - building_loss - vegetation_loss
|
||||
+ reflection_gain + water_reflection_gain)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update API & Frontend Settings (1 hour)
|
||||
|
||||
**Update presets in coverage.py:**
|
||||
```python
|
||||
PRESETS = {
|
||||
"fast": {
|
||||
"use_terrain": True,
|
||||
"use_buildings": False,
|
||||
"use_materials": False,
|
||||
"use_dominant_path": False,
|
||||
"use_street_canyon": False,
|
||||
"use_reflections": False,
|
||||
"use_water_reflection": False,
|
||||
"use_vegetation": False,
|
||||
},
|
||||
# ... standard, detailed ...
|
||||
"full": {
|
||||
"use_terrain": True,
|
||||
"use_buildings": True,
|
||||
"use_materials": True,
|
||||
"use_dominant_path": True,
|
||||
"use_street_canyon": True,
|
||||
"use_reflections": True,
|
||||
"use_water_reflection": True,
|
||||
"use_vegetation": True,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Summary
|
||||
|
||||
**New Files:**
|
||||
```
|
||||
app/services/
|
||||
├── spatial_index.py # R-tree for fast building lookup
|
||||
├── water_service.py # OSM water bodies
|
||||
└── vegetation_service.py # OSM forest/vegetation
|
||||
|
||||
data/
|
||||
├── water/ # Water bodies cache
|
||||
└── vegetation/ # Vegetation cache
|
||||
```
|
||||
|
||||
**Modified Files:**
|
||||
```
|
||||
app/services/
|
||||
├── buildings_service.py # Fix parsing bugs
|
||||
├── coverage_service.py # Integrate new services
|
||||
└── reflection_service.py # Add ground reflection
|
||||
|
||||
app/api/routes/
|
||||
└── coverage.py # Timeout, new settings
|
||||
|
||||
requirements.txt # Add Rtree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Install new dependency
|
||||
cd /opt/rfcp/backend
|
||||
source venv/bin/activate
|
||||
pip install Rtree
|
||||
|
||||
# Restart
|
||||
sudo systemctl restart rfcp-backend
|
||||
|
||||
# Test parsing fix
|
||||
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],"settings":{"radius":1000,"resolution":100,"preset":"standard"}}'
|
||||
|
||||
# Test full preset with new features
|
||||
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],"settings":{"radius":500,"resolution":50,"preset":"full"}}'
|
||||
|
||||
# Run test scripts
|
||||
./rfcp-integration-test.sh
|
||||
./rfcp-propagation-test.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] `building:levels = "1а"` no longer crashes
|
||||
- [ ] R-tree spatial index speeds up building queries
|
||||
- [ ] Water bodies fetched and cached
|
||||
- [ ] Vegetation areas fetched and cached
|
||||
- [ ] Coverage includes `vegetation_loss` field
|
||||
- [ ] Full preset uses all new features
|
||||
- [ ] No timeout on reasonable requests (5km, 100m resolution)
|
||||
- [ ] All tests pass (21/21 integration, 8/8 propagation)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- R-tree requires `libspatialindex` system library
|
||||
- Install on Ubuntu: `apt install libspatialindex-dev`
|
||||
- Vegetation loss is seasonal — default to summer
|
||||
- Water reflection most noticeable near large lakes/rivers
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next: 1.6.1 or 2.1
|
||||
|
||||
**1.6.1 — Extra Factors (optional):**
|
||||
- Weather/rain attenuation
|
||||
- Indoor penetration layer
|
||||
- Time-of-day atmospheric effects
|
||||
|
||||
**2.1 — Desktop Installer:**
|
||||
- Electron packaging
|
||||
- Offline mode
|
||||
- GPU acceleration
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
549
docs/devlog/back/RFCP-Iteration-1.6.1-Extra-Factors.md
Normal file
549
docs/devlog/back/RFCP-Iteration-1.6.1-Extra-Factors.md
Normal file
@@ -0,0 +1,549 @@
|
||||
# RFCP Iteration 1.6.1: Extra Factors
|
||||
|
||||
**Date:** January 31, 2025
|
||||
**Type:** Backend Enhancement
|
||||
**Estimated:** 4-6 hours
|
||||
**Location:** `/opt/rfcp/backend/`
|
||||
**Priority:** Low — nice to have for realism
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Add weather effects, indoor penetration loss, and atmospheric absorption for more realistic RF propagation modeling.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Features
|
||||
|
||||
### 1. Rain Attenuation (ITU-R P.838)
|
||||
|
||||
**Theory:** Rain causes signal attenuation, especially at higher frequencies (>10 GHz). Even at LTE frequencies (700-2600 MHz), heavy rain can add 1-3 dB loss.
|
||||
|
||||
**app/services/weather_service.py:**
|
||||
```python
|
||||
from typing import Optional
|
||||
import math
|
||||
|
||||
class WeatherService:
|
||||
"""ITU-R P.838 rain attenuation model"""
|
||||
|
||||
# ITU-R P.838-3 coefficients for horizontal polarization
|
||||
# Format: (frequency_GHz, k, alpha)
|
||||
RAIN_COEFFICIENTS = {
|
||||
0.7: (0.0000387, 0.912),
|
||||
1.0: (0.0000887, 0.949),
|
||||
1.8: (0.000292, 1.021),
|
||||
2.1: (0.000425, 1.052),
|
||||
2.6: (0.000683, 1.091),
|
||||
3.5: (0.00138, 1.149),
|
||||
5.0: (0.00361, 1.206),
|
||||
10.0: (0.0245, 1.200),
|
||||
20.0: (0.0906, 1.099),
|
||||
30.0: (0.175, 1.021),
|
||||
}
|
||||
|
||||
def calculate_rain_attenuation(
|
||||
self,
|
||||
frequency_mhz: float,
|
||||
distance_km: float,
|
||||
rain_rate: float, # mm/h
|
||||
polarization: str = "horizontal"
|
||||
) -> float:
|
||||
"""
|
||||
Calculate rain attenuation in dB
|
||||
|
||||
Args:
|
||||
frequency_mhz: Frequency in MHz
|
||||
distance_km: Path length in km
|
||||
rain_rate: Rain rate in mm/h (0=none, 5=light, 25=moderate, 50=heavy)
|
||||
polarization: "horizontal" or "vertical"
|
||||
|
||||
Returns:
|
||||
Attenuation in dB
|
||||
"""
|
||||
if rain_rate <= 0:
|
||||
return 0.0
|
||||
|
||||
freq_ghz = frequency_mhz / 1000
|
||||
|
||||
# Get interpolated coefficients
|
||||
k, alpha = self._get_coefficients(freq_ghz)
|
||||
|
||||
# Specific attenuation (dB/km)
|
||||
gamma_r = k * (rain_rate ** alpha)
|
||||
|
||||
# Effective path length reduction for longer paths
|
||||
# Rain cells are typically 2-5 km
|
||||
if distance_km > 2:
|
||||
reduction_factor = 1 / (1 + distance_km / 35)
|
||||
effective_distance = distance_km * reduction_factor
|
||||
else:
|
||||
effective_distance = distance_km
|
||||
|
||||
attenuation = gamma_r * effective_distance
|
||||
|
||||
return min(attenuation, 30.0) # Cap at 30 dB
|
||||
|
||||
def _get_coefficients(self, freq_ghz: float) -> tuple[float, float]:
|
||||
"""Interpolate rain coefficients for frequency"""
|
||||
freqs = sorted(self.RAIN_COEFFICIENTS.keys())
|
||||
|
||||
# Find surrounding frequencies
|
||||
if freq_ghz <= freqs[0]:
|
||||
return self.RAIN_COEFFICIENTS[freqs[0]]
|
||||
if freq_ghz >= freqs[-1]:
|
||||
return self.RAIN_COEFFICIENTS[freqs[-1]]
|
||||
|
||||
for i in range(len(freqs) - 1):
|
||||
if freqs[i] <= freq_ghz <= freqs[i + 1]:
|
||||
f1, f2 = freqs[i], freqs[i + 1]
|
||||
k1, a1 = self.RAIN_COEFFICIENTS[f1]
|
||||
k2, a2 = self.RAIN_COEFFICIENTS[f2]
|
||||
|
||||
# Linear interpolation
|
||||
t = (freq_ghz - f1) / (f2 - f1)
|
||||
k = k1 + t * (k2 - k1)
|
||||
alpha = a1 + t * (a2 - a1)
|
||||
|
||||
return k, alpha
|
||||
|
||||
return self.RAIN_COEFFICIENTS[freqs[0]]
|
||||
|
||||
@staticmethod
|
||||
def rain_rate_from_description(description: str) -> float:
|
||||
"""Convert rain description to rate"""
|
||||
rates = {
|
||||
"none": 0.0,
|
||||
"drizzle": 2.5,
|
||||
"light": 5.0,
|
||||
"moderate": 12.5,
|
||||
"heavy": 25.0,
|
||||
"very_heavy": 50.0,
|
||||
"extreme": 100.0,
|
||||
}
|
||||
return rates.get(description.lower(), 0.0)
|
||||
|
||||
|
||||
weather_service = WeatherService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Indoor Penetration Loss (ITU-R P.2109)
|
||||
|
||||
**Theory:** Signals lose strength when entering buildings. Loss depends on building type, frequency, and wall materials.
|
||||
|
||||
**app/services/indoor_service.py:**
|
||||
```python
|
||||
from typing import Optional
|
||||
import math
|
||||
|
||||
class IndoorService:
|
||||
"""ITU-R P.2109 building entry loss model"""
|
||||
|
||||
# Building Entry Loss (BEL) by construction type at 2 GHz
|
||||
# Format: (median_loss_dB, std_dev_dB)
|
||||
BUILDING_TYPES = {
|
||||
"none": (0.0, 0.0), # Outdoor only
|
||||
"light": (8.0, 4.0), # Wood frame, large windows
|
||||
"medium": (15.0, 6.0), # Brick, standard windows
|
||||
"heavy": (22.0, 8.0), # Concrete, small windows
|
||||
"basement": (30.0, 10.0), # Underground
|
||||
"vehicle": (6.0, 3.0), # Inside car
|
||||
"train": (20.0, 5.0), # Inside train
|
||||
}
|
||||
|
||||
# Frequency correction factor (dB per octave above 2 GHz)
|
||||
FREQ_CORRECTION = 2.5
|
||||
|
||||
def calculate_indoor_loss(
|
||||
self,
|
||||
frequency_mhz: float,
|
||||
building_type: str = "medium",
|
||||
floor_number: int = 0,
|
||||
depth_m: float = 0.0,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate building entry/indoor penetration loss
|
||||
|
||||
Args:
|
||||
frequency_mhz: Frequency in MHz
|
||||
building_type: Type of building construction
|
||||
floor_number: Floor number (0=ground, negative=basement)
|
||||
depth_m: Distance from exterior wall in meters
|
||||
|
||||
Returns:
|
||||
Loss in dB
|
||||
"""
|
||||
if building_type == "none":
|
||||
return 0.0
|
||||
|
||||
base_loss, _ = self.BUILDING_TYPES.get(building_type, (15.0, 6.0))
|
||||
|
||||
# Frequency correction
|
||||
freq_ghz = frequency_mhz / 1000
|
||||
if freq_ghz > 2.0:
|
||||
octaves = math.log2(freq_ghz / 2.0)
|
||||
freq_correction = self.FREQ_CORRECTION * octaves
|
||||
else:
|
||||
freq_correction = 0.0
|
||||
|
||||
# Floor correction (higher floors = less loss due to better angle)
|
||||
if floor_number > 0:
|
||||
floor_correction = -1.5 * min(floor_number, 10)
|
||||
elif floor_number < 0:
|
||||
# Basement - additional loss
|
||||
floor_correction = 5.0 * abs(floor_number)
|
||||
else:
|
||||
floor_correction = 0.0
|
||||
|
||||
# Depth correction (signal attenuates inside building)
|
||||
# Approximately 0.5 dB per meter for first 10m
|
||||
depth_correction = 0.5 * min(depth_m, 20)
|
||||
|
||||
total_loss = base_loss + freq_correction + floor_correction + depth_correction
|
||||
|
||||
return max(0.0, min(total_loss, 50.0)) # Clamp 0-50 dB
|
||||
|
||||
def calculate_outdoor_to_indoor_coverage(
|
||||
self,
|
||||
outdoor_rsrp: float,
|
||||
building_type: str,
|
||||
frequency_mhz: float,
|
||||
) -> float:
|
||||
"""Calculate expected indoor RSRP from outdoor signal"""
|
||||
indoor_loss = self.calculate_indoor_loss(frequency_mhz, building_type)
|
||||
return outdoor_rsrp - indoor_loss
|
||||
|
||||
|
||||
indoor_service = IndoorService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Atmospheric Absorption (ITU-R P.676)
|
||||
|
||||
**Theory:** Oxygen and water vapor absorb RF energy. Significant at mmWave (>10 GHz), minor at LTE bands.
|
||||
|
||||
**app/services/atmospheric_service.py:**
|
||||
```python
|
||||
import math
|
||||
|
||||
class AtmosphericService:
|
||||
"""ITU-R P.676 atmospheric absorption model"""
|
||||
|
||||
# Simplified model for frequencies < 50 GHz
|
||||
# Standard atmosphere: T=15°C, P=1013 hPa, humidity=50%
|
||||
|
||||
def calculate_atmospheric_loss(
|
||||
self,
|
||||
frequency_mhz: float,
|
||||
distance_km: float,
|
||||
temperature_c: float = 15.0,
|
||||
humidity_percent: float = 50.0,
|
||||
altitude_m: float = 0.0,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate atmospheric absorption loss
|
||||
|
||||
Args:
|
||||
frequency_mhz: Frequency in MHz
|
||||
distance_km: Path length in km
|
||||
temperature_c: Temperature in Celsius
|
||||
humidity_percent: Relative humidity (0-100)
|
||||
altitude_m: Altitude above sea level
|
||||
|
||||
Returns:
|
||||
Loss in dB
|
||||
"""
|
||||
freq_ghz = frequency_mhz / 1000
|
||||
|
||||
# Below 1 GHz - negligible
|
||||
if freq_ghz < 1.0:
|
||||
return 0.0
|
||||
|
||||
# Calculate specific attenuation (dB/km)
|
||||
gamma = self._specific_attenuation(freq_ghz, temperature_c, humidity_percent)
|
||||
|
||||
# Altitude correction (less atmosphere at higher altitudes)
|
||||
altitude_factor = math.exp(-altitude_m / 8500) # Scale height ~8.5km
|
||||
|
||||
loss = gamma * distance_km * altitude_factor
|
||||
|
||||
return min(loss, 20.0) # Cap for reasonable distances
|
||||
|
||||
def _specific_attenuation(
|
||||
self,
|
||||
freq_ghz: float,
|
||||
temperature_c: float,
|
||||
humidity_percent: float,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate specific attenuation in dB/km
|
||||
|
||||
Simplified ITU-R P.676 model
|
||||
"""
|
||||
# Water vapor density (g/m³) - simplified
|
||||
# Saturation vapor pressure (hPa)
|
||||
es = 6.1121 * math.exp((18.678 - temperature_c / 234.5) *
|
||||
(temperature_c / (257.14 + temperature_c)))
|
||||
rho = (humidity_percent / 100) * es * 216.7 / (273.15 + temperature_c)
|
||||
|
||||
# Oxygen absorption (dominant at 60 GHz, minor below 10 GHz)
|
||||
if freq_ghz < 10:
|
||||
gamma_o = 0.001 * freq_ghz ** 2 # Very small
|
||||
elif freq_ghz < 57:
|
||||
gamma_o = 0.001 * (freq_ghz / 10) ** 2.5
|
||||
else:
|
||||
# Near 60 GHz resonance
|
||||
gamma_o = 15.0 # Peak absorption
|
||||
|
||||
# Water vapor absorption (peaks at 22 GHz and 183 GHz)
|
||||
if freq_ghz < 10:
|
||||
gamma_w = 0.0001 * rho * freq_ghz ** 2
|
||||
elif freq_ghz < 50:
|
||||
gamma_w = 0.001 * rho * (freq_ghz / 22) ** 2
|
||||
else:
|
||||
gamma_w = 0.01 * rho
|
||||
|
||||
return gamma_o + gamma_w
|
||||
|
||||
@staticmethod
|
||||
def get_weather_description(loss_db: float) -> str:
|
||||
"""Describe atmospheric conditions based on loss"""
|
||||
if loss_db < 0.1:
|
||||
return "clear"
|
||||
elif loss_db < 0.5:
|
||||
return "normal"
|
||||
elif loss_db < 2.0:
|
||||
return "humid"
|
||||
else:
|
||||
return "foggy"
|
||||
|
||||
|
||||
atmospheric_service = AtmosphericService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Integration into Coverage Service
|
||||
|
||||
**Update app/services/coverage_service.py:**
|
||||
```python
|
||||
# Add imports
|
||||
from app.services.weather_service import weather_service
|
||||
from app.services.indoor_service import indoor_service
|
||||
from app.services.atmospheric_service import atmospheric_service
|
||||
|
||||
# Update CoverageSettings
|
||||
class CoverageSettings(BaseModel):
|
||||
# ... existing fields ...
|
||||
|
||||
# Weather
|
||||
rain_rate: float = 0.0 # mm/h (0=none, 5=light, 25=heavy)
|
||||
|
||||
# Indoor
|
||||
indoor_loss_type: str = "none" # none, light, medium, heavy
|
||||
|
||||
# Atmospheric
|
||||
use_atmospheric: bool = False
|
||||
temperature_c: float = 15.0
|
||||
humidity_percent: float = 50.0
|
||||
|
||||
# Update _calculate_point()
|
||||
async def _calculate_point(self, ...):
|
||||
# ... existing calculations ...
|
||||
|
||||
# Rain attenuation
|
||||
rain_loss = 0.0
|
||||
if settings.rain_rate > 0:
|
||||
rain_loss = weather_service.calculate_rain_attenuation(
|
||||
site.frequency,
|
||||
distance / 1000, # km
|
||||
settings.rain_rate
|
||||
)
|
||||
|
||||
# Indoor penetration
|
||||
indoor_loss = 0.0
|
||||
if settings.indoor_loss_type != "none":
|
||||
indoor_loss = indoor_service.calculate_indoor_loss(
|
||||
site.frequency,
|
||||
settings.indoor_loss_type
|
||||
)
|
||||
|
||||
# Atmospheric absorption
|
||||
atmo_loss = 0.0
|
||||
if settings.use_atmospheric:
|
||||
atmo_loss = atmospheric_service.calculate_atmospheric_loss(
|
||||
site.frequency,
|
||||
distance / 1000,
|
||||
settings.temperature_c,
|
||||
settings.humidity_percent
|
||||
)
|
||||
|
||||
# Final RSRP
|
||||
rsrp = (site.power + site.gain - path_loss - antenna_loss
|
||||
- terrain_loss - building_loss - vegetation_loss
|
||||
- rain_loss - indoor_loss - atmo_loss
|
||||
+ reflection_gain + water_reflection_gain)
|
||||
|
||||
return CoveragePoint(
|
||||
# ... existing fields ...
|
||||
rain_loss=rain_loss,
|
||||
indoor_loss=indoor_loss,
|
||||
atmospheric_loss=atmo_loss,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Update API Response
|
||||
|
||||
**Update CoveragePoint model:**
|
||||
```python
|
||||
class CoveragePoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
rsrp: float
|
||||
distance: float
|
||||
has_los: bool
|
||||
terrain_loss: float = 0.0
|
||||
building_loss: float = 0.0
|
||||
vegetation_loss: float = 0.0
|
||||
reflection_gain: float = 0.0
|
||||
rain_loss: float = 0.0 # NEW
|
||||
indoor_loss: float = 0.0 # NEW
|
||||
atmospheric_loss: float = 0.0 # NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Frontend UI Updates
|
||||
|
||||
**Add to CoverageSettings panel:**
|
||||
```typescript
|
||||
// Weather section
|
||||
<div className="settings-section">
|
||||
<h4>Weather Conditions</h4>
|
||||
|
||||
<select
|
||||
value={settings.rain_rate}
|
||||
onChange={(e) => updateSettings({ rain_rate: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>No Rain</option>
|
||||
<option value={2.5}>Drizzle</option>
|
||||
<option value={5}>Light Rain</option>
|
||||
<option value={12.5}>Moderate Rain</option>
|
||||
<option value={25}>Heavy Rain</option>
|
||||
<option value={50}>Very Heavy Rain</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
// Indoor section
|
||||
<div className="settings-section">
|
||||
<h4>Indoor Coverage</h4>
|
||||
|
||||
<select
|
||||
value={settings.indoor_loss_type}
|
||||
onChange={(e) => updateSettings({ indoor_loss_type: e.target.value })}
|
||||
>
|
||||
<option value="none">Outdoor Only</option>
|
||||
<option value="light">Light Building (wood, glass)</option>
|
||||
<option value="medium">Medium Building (brick)</option>
|
||||
<option value="heavy">Heavy Building (concrete)</option>
|
||||
<option value="vehicle">Inside Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Summary
|
||||
|
||||
**New Files:**
|
||||
```
|
||||
backend/app/services/
|
||||
├── weather_service.py # Rain attenuation
|
||||
├── indoor_service.py # Building entry loss
|
||||
└── atmospheric_service.py # Atmospheric absorption
|
||||
```
|
||||
|
||||
**Modified Files:**
|
||||
```
|
||||
backend/app/
|
||||
├── services/coverage_service.py # Integration
|
||||
├── api/routes/coverage.py # New settings
|
||||
└── models/coverage.py # New fields
|
||||
|
||||
frontend/src/
|
||||
├── types/coverage.ts # New fields
|
||||
├── services/api.ts # New settings
|
||||
├── store/coverage.ts # New state
|
||||
└── components/panels/CoverageSettingsPanel.tsx # UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Test rain attenuation
|
||||
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],
|
||||
"settings":{
|
||||
"radius":1000,
|
||||
"resolution":100,
|
||||
"preset":"fast",
|
||||
"rain_rate":25
|
||||
}
|
||||
}'
|
||||
|
||||
# Test indoor penetration
|
||||
curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}],
|
||||
"settings":{
|
||||
"radius":1000,
|
||||
"resolution":100,
|
||||
"preset":"fast",
|
||||
"indoor_loss_type":"medium"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Rain attenuation calculated correctly (0 dB at 0 mm/h, ~5 dB at 25 mm/h for 1km)
|
||||
- [ ] Indoor loss applied uniformly (~15 dB for medium building)
|
||||
- [ ] Atmospheric loss minimal at LTE frequencies (<0.5 dB for 10km)
|
||||
- [ ] Frontend shows weather/indoor selectors
|
||||
- [ ] All presets still work
|
||||
- [ ] Tests pass
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Rain attenuation most significant at mmWave (5G NR FR2)
|
||||
- Indoor loss is uniform for coverage area (all points affected equally)
|
||||
- Atmospheric loss negligible below 6 GHz for typical distances
|
||||
- These are "what-if" analysis features, not real-time weather integration
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next: 2.1 — Desktop Installer
|
||||
|
||||
After 1.6.1:
|
||||
- Electron packaging
|
||||
- Offline mode with local data
|
||||
- GPU acceleration for fast calculations
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
Reference in New Issue
Block a user