Files
rfcp/docs/RFCP-TechSpec-v3.0.md
2026-01-30 20:39:13 +02:00

2374 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RFCP - RF Coverage Planning Tool
## Technical Specification v3.0 (Final)
**Project:** RF Coverage Planner
**Version:** 3.0 (PWA/Offline-First/Manual Input)
**Target URL:** https://rfcp.eliah.one
**Repository:** https://git.eliah.one/mytec/rfcp.git
**Created:** 2025-01-30
**Author:** Claude + Олег (mytec)
**Status:** 🚀 Ready for Implementation
---
## Executive Summary
RFCP - Progressive Web Application для планування та візуалізації покриття wireless мереж (LTE, UHF/VHF) з повною підтримкою offline режиму. Інструмент надає військовим зв'язківцям гнучкий, точний засіб планування розгортання радіосистем на місцевості з урахуванням рельєфу.
### Core Philosophy
**"Flexibility over Presets"** - Користувач вводить параметри вручну, розуміє що робить, експериментує. Ніяких жорстких обмежень на обладнання.
### Key Features
- 📴 **100% Offline Capable** - працює без інтернету після першого завантаження
- 🎛️ **Manual Input First** - повна гнучкість параметрів, без прив'язки до конкретного обладнання
- 📱 **Mobile-First** - оптимізовано для планшетів та телефонів
- 🗺️ **High-Res Terrain** - 30m SRTM elevation data для точних розрахунків
- 🔐 **Optional Auth** - MAS OAuth для синхронізації між пристроями
- 📦 **Smart Download** - terrain on-demand для 2-3 областей (~800MB-1.2GB)
-**Fast** - всі RF розрахунки в браузері (Web Workers)
- 🌐 **Universal** - LTE (MVP) → UHF/VHF (v1.1+)
### Target Users
- Військові зв'язківці (планування tactical LTE/radio networks)
- Телеком інженери (proof-of-concept deployments)
- UMTC infrastructure планувальники
- Amateur radio operators (майбутнє)
---
## Architecture Overview
### High-Level Architecture
```
┌─────────────────────────────────────────────────────┐
│ Browser / Mobile Device │
│ ┌───────────────────────────────────────────────┐ │
│ │ RFCP PWA Application │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ UI Layer │ │ Service Worker │ │ │
│ │ │ (React) │◄────►│ (Offline Cache) │ │ │
│ │ │ │ │ │ │ │
│ │ │ - Manual │ │ - Map tiles │ │ │
│ │ │ Input UI │ │ - App assets │ │ │
│ │ │ - Dynamic │ │ - Terrain data │ │ │
│ │ │ Bands │ │ │ │ │
│ │ └──────────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼───────────────────────────────┐ │ │
│ │ │ RF Calculation Engine │ │ │
│ │ │ (TypeScript + Web Workers) │ │ │
│ │ │ │ │ │
│ │ │ - Universal FSPL (any frequency) │ │ │
│ │ │ - Terrain loss (30m SRTM) │ │ │
│ │ │ - Sector antenna patterns │ │ │
│ │ │ - Multi-site aggregation │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼───────────────────────────────┐ │ │
│ │ │ Offline Storage │ │ │
│ │ │ - IndexedDB (Terrain + Configs) │ │ │
│ │ │ - LocalStorage (Settings) │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
│ (Optional Network Connection)
┌─────────────────────────────────────────────────────┐
│ Backend API (FastAPI) - Optional │
│ ┌─────────────────┐ ┌────────────────────┐ │
│ │ MAS OAuth │ │ Terrain Data CDN │ │
│ │ Integration │ │ (30m SRTM files) │ │
│ └─────────────────┘ └────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Config Sync (for multi-device users) │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
---
## Technology Stack
### Frontend (PWA Core)
```yaml
Core Framework:
- React: 18.x (with TypeScript)
- TypeScript: 5.x
- Vite: 5.x (build tool)
- Tailwind CSS: 3.x
Mapping Libraries:
- Leaflet: 1.9.x (map rendering)
- React-Leaflet: 4.x
- Leaflet.heat: 0.2.x (heatmap visualization)
Offline Capabilities:
- Workbox: 7.x (Service Worker management)
- Dexie.js: 4.x (IndexedDB wrapper)
- localforage: 1.x (fallback storage)
State Management:
- Zustand: 4.x (lightweight state)
- React Query: 5.x (server state, optional)
RF Calculations:
- Pure TypeScript (no dependencies)
- Web Workers for parallel processing
- Custom SRTM parser (30m resolution)
UI Components:
- Headless UI (accessible components)
- Heroicons (icons)
- React Hook Form (forms)
- React Range (sliders)
```
### Backend (Optional/Supplementary)
```yaml
API Framework:
- FastAPI: 0.110.x
- Python: 3.11+
- Uvicorn: ASGI server
Authentication:
- matrix-auth-server OAuth client
- JWT token validation
Data Storage:
- SQLite (config backups)
- File system (30m SRTM tiles)
CDN/Static Files:
- Caddy reverse proxy
- Pre-processed SRTM tiles
```
### Infrastructure
```yaml
Hosting:
- VPS-A (Germany) or dedicated container
- Caddy 2.x (reverse proxy + TLS)
- Docker + Docker Compose
Domain:
- rfcp.eliah.one
Monitoring:
- Uptime Kuma integration
```
---
## Data Models
### TypeScript Types (Frontend)
```typescript
// types/site.ts
export interface Site {
// Identity
id: string; // UUID
name: string; // User-defined name
// Location
lat: number; // Latitude
lon: number; // Longitude
height: number; // meters above ground (1-100)
// RF Parameters (all manually editable)
power: number; // dBm (10-50)
gain: number; // dBi (0-25)
frequency: number; // MHz (400-6000, user can enter any value)
// Antenna Configuration
antennaType: 'omni' | 'sector';
azimuth?: number; // degrees (0-360), for sector only
beamwidth?: number; // degrees (30-120), for sector only
// UI/Display
color: string; // hex color for map marker
visible: boolean; // show/hide on map
// User Metadata (optional, free-text)
notes?: string; // e.g., "ZTE B8200 + Huawei APX4518R0"
equipment?: string; // free-form equipment description
// Timestamps
createdAt: Date;
updatedAt: Date;
}
// types/coverage.ts
export interface CoveragePoint {
lat: number;
lon: number;
rsrp: number; // dBm (calculated signal strength)
siteId: string; // which site provides this coverage
}
export interface CoverageResult {
points: CoveragePoint[];
calculationTime: number; // milliseconds
totalPoints: number;
bounds: LatLngBounds;
settings: CoverageSettings;
}
export interface CoverageSettings {
radius: number; // km (calculation radius)
resolution: number; // meters (grid resolution)
rsrpThreshold: number; // dBm (minimum signal to display)
}
// types/terrain.ts
export interface TerrainTile {
region: string; // e.g., "ukraine-east"
bounds: {
north: number;
south: number;
east: number;
west: number;
};
resolution: 30; // arc-seconds (30m SRTM)
data: Int16Array; // elevation data
width: number; // tile width in pixels
height: number; // tile height in pixels
downloadedAt: number; // timestamp
sizeBytes: number; // compressed size
}
// types/frequency.ts
export interface FrequencyBand {
value: number; // MHz
name: string; // e.g., "Band 3"
range: string; // e.g., "1710-1880 MHz"
type: 'LTE' | 'UHF' | 'VHF' | '5G' | 'Custom';
characteristics: {
range: 'short' | 'medium' | 'long';
penetration: 'poor' | 'fair' | 'good' | 'excellent';
typical: string; // use case description
};
}
// types/project.ts
export interface ProjectConfig {
id: string;
name: string;
description?: string;
sites: Site[];
mapCenter: [number, number];
mapZoom: number;
coverageSettings: CoverageSettings;
createdAt: Date;
updatedAt: Date;
syncedAt?: Date; // for cloud sync
}
// types/settings.ts
export interface AppSettings {
// UI
theme: 'light' | 'dark' | 'auto';
language: 'uk' | 'en';
// Units
units: 'metric'; // only metric for now
// Defaults
defaultFrequency: number; // MHz
defaultPower: number; // dBm
defaultHeight: number; // meters
defaultGain: number; // dBi
// Features
autoDownloadTerrain: boolean;
enableCloudSync: boolean;
// Map
mapTileServer: string;
defaultMapZoom: number;
}
```
### IndexedDB Schema (Dexie)
```typescript
// db/schema.ts
import Dexie, { Table } from 'dexie';
export interface DBTerrainTile {
region: string; // primary key
bounds: {
north: number;
south: number;
east: number;
west: number;
};
resolution: 30;
data: ArrayBuffer; // compressed elevation data
width: number;
height: number;
downloadedAt: number;
sizeBytes: number;
}
export interface DBProjectConfig {
id: string; // primary key (UUID)
name: string;
data: string; // JSON serialized ProjectConfig
createdAt: number;
updatedAt: number;
syncedAt?: number;
}
export class RFCPDatabase extends Dexie {
terrain!: Table<DBTerrainTile, string>;
projects!: Table<DBProjectConfig, string>;
constructor() {
super('rfcp-db');
this.version(1).stores({
terrain: 'region, downloadedAt',
projects: 'id, updatedAt, syncedAt'
});
}
}
export const db = new RFCPDatabase();
```
---
## Frequency Bands & Configuration
### Common Frequency Bands (Reference Data)
```typescript
// constants/frequencies.ts
export const COMMON_FREQUENCIES: FrequencyBand[] = [
// LTE Bands
{
value: 800,
name: 'Band 20',
range: '791-862 MHz',
type: 'LTE',
characteristics: {
range: 'long',
penetration: 'excellent',
typical: 'Rural coverage, deep building penetration'
}
},
{
value: 1800,
name: 'Band 3',
range: '1710-1880 MHz',
type: 'LTE',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'Urban/suburban, most common in Ukraine'
}
},
{
value: 1900,
name: 'Band 2',
range: '1850-1990 MHz',
type: 'LTE',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'North America, some military equipment'
}
},
{
value: 2600,
name: 'Band 7',
range: '2500-2690 MHz',
type: 'LTE',
characteristics: {
range: 'short',
penetration: 'fair',
typical: 'High capacity urban, shorter range'
}
},
// Future: UHF/VHF (v1.1)
{
value: 150,
name: 'VHF High',
range: '136-174 MHz',
type: 'VHF',
characteristics: {
range: 'long',
penetration: 'excellent',
typical: 'Tactical radio, emergency services'
}
},
{
value: 450,
name: 'UHF',
range: '400-470 MHz',
type: 'UHF',
characteristics: {
range: 'medium',
penetration: 'good',
typical: 'Military tactical radio, PMR446'
}
},
// Future: 5G NR (v1.2)
{
value: 3500,
name: 'Band 42/43 (n78)',
range: '3400-3800 MHz',
type: '5G',
characteristics: {
range: 'short',
penetration: 'poor',
typical: '5G NR, high bandwidth'
}
}
];
export function getFrequencyInfo(frequency: number): FrequencyBand | null {
// Find closest match
return COMMON_FREQUENCIES.find(band =>
Math.abs(band.value - frequency) < 50
) || null;
}
export function getWavelength(frequencyMHz: number): string {
const wavelengthMeters = 300 / frequencyMHz;
if (wavelengthMeters >= 1) {
return `${wavelengthMeters.toFixed(2)} m`;
} else {
return `${(wavelengthMeters * 100).toFixed(1)} cm`;
}
}
```
---
## RF Propagation Models
### Free Space Path Loss (FSPL) - Universal
```typescript
/**
* Calculate Free Space Path Loss
* Works with ANY frequency (LTE, UHF, VHF, 5G)
*
* @param distanceKm - Distance in kilometers
* @param frequencyMHz - Frequency in megahertz
* @returns Path loss in dB
*/
export function calculateFSPL(distanceKm: number, frequencyMHz: number): number {
if (distanceKm <= 0) return 0;
// FSPL(dB) = 20×log₁₀(d) + 20×log₁₀(f) + 32.45
// This formula is universal for all RF frequencies
return 20 * Math.log10(distanceKm)
+ 20 * Math.log10(frequencyMHz)
+ 32.45;
}
/**
* Calculate RSRP at receiver location
* Universal calculator - works with any user-provided parameters
*
* @param site - Transmitter site configuration (user inputs)
* @param rxLat - Receiver latitude
* @param rxLon - Receiver longitude
* @param terrainLoss - Additional terrain loss in dB
* @returns RSRP in dBm
*/
export function calculateRSRP(
site: Site,
rxLat: number,
rxLon: number,
terrainLoss: number = 0
): number {
const distanceKm = haversineDistance(
site.lat, site.lon,
rxLat, rxLon
);
// Free space path loss (universal formula)
const fspl = calculateFSPL(distanceKm, site.frequency);
// Link budget calculation
// RSRP = P_tx + G_tx - FSPL - TerrainLoss - SectorLoss
let rsrp = site.power + site.gain - fspl - terrainLoss;
// Apply antenna pattern loss (for sector antennas)
if (site.antennaType === 'sector' && site.azimuth !== undefined) {
const bearing = calculateBearing(site.lat, site.lon, rxLat, rxLon);
const relativeAngle = Math.abs(bearing - site.azimuth);
const normalizedAngle = relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
const patternLoss = calculateSectorPatternLoss(
normalizedAngle,
site.beamwidth || 65
);
rsrp -= patternLoss;
}
return rsrp;
}
```
### Terrain Loss Model (Enhanced for 30m SRTM)
```typescript
/**
* Calculate additional path loss due to terrain
* Uses 30m resolution SRTM data for accurate results
* Implements simplified knife-edge diffraction
*/
export function calculateTerrainLoss(
txLat: number,
txLon: number,
txHeight: number,
rxLat: number,
rxLon: number,
rxHeight: number = 1.5,
terrain: TerrainManager
): number {
// Get elevations from 30m SRTM data
const txElevation = terrain.getElevation(txLat, txLon);
const rxElevation = terrain.getElevation(rxLat, rxLon);
const distance = haversineDistance(txLat, txLon, rxLat, rxLon);
// Effective antenna heights above sea level
const h1 = txHeight + txElevation;
const h2 = rxHeight + rxElevation;
// Sample terrain profile (5 points between TX and RX)
const profilePoints = 5;
let maxObstruction = 0;
for (let i = 1; i < profilePoints; i++) {
const ratio = i / profilePoints;
const midLat = txLat + (rxLat - txLat) * ratio;
const midLon = txLon + (rxLon - txLon) * ratio;
const midElevation = terrain.getElevation(midLat, midLon);
// Line-of-sight height at this point
const losHeight = h1 + (h2 - h1) * ratio;
// Obstruction height
const obstruction = midElevation - losHeight;
if (obstruction > maxObstruction) {
maxObstruction = obstruction;
}
}
if (maxObstruction > 0) {
// Knife-edge diffraction loss (simplified)
// More accurate with 30m resolution
const v = maxObstruction * Math.sqrt(2 * distance / 0.3); // wavelength ~0.3m for 1GHz
const diffraction = 6.9 + 20 * Math.log10(Math.sqrt((v - 0.1) * (v - 0.1) + 1) + v - 0.1);
return Math.min(diffraction, 40); // Cap at 40 dB
}
return 0; // Clear line-of-sight
}
```
### Sector Antenna Pattern
```typescript
/**
* Calculate antenna pattern loss for sector antennas
* Based on 3GPP antenna models
*
* @param angleOffBoresight - Angle from antenna boresight in degrees (0-180)
* @param beamwidth - 3dB beamwidth in degrees
* @returns Pattern loss in dB
*/
export function calculateSectorPatternLoss(
angleOffBoresight: number,
beamwidth: number = 65
): number {
// 3GPP antenna pattern model
const theta3dB = beamwidth / 2;
const sideLobeLevel = 20; // dB
// Main lobe
if (angleOffBoresight <= theta3dB) {
return 12 * Math.pow(angleOffBoresight / theta3dB, 2);
}
// Side/back lobes
return Math.min(
12 * Math.pow(angleOffBoresight / theta3dB, 2),
sideLobeLevel
);
}
```
### Signal Quality Thresholds
```typescript
// constants/rsrp-thresholds.ts
export const RSRP_THRESHOLDS = {
EXCELLENT: -70, // > -70 dBm (very strong)
GOOD: -85, // -70 to -85 dBm (strong)
FAIR: -100, // -85 to -100 dBm (acceptable)
POOR: -110, // -100 to -110 dBm (marginal)
WEAK: -120, // -110 to -120 dBm (very weak)
NO_SERVICE: -120 // < -120 dBm (no service)
} as const;
export function getSignalQuality(rsrp: number): string {
if (rsrp >= RSRP_THRESHOLDS.EXCELLENT) return 'excellent';
if (rsrp >= RSRP_THRESHOLDS.GOOD) return 'good';
if (rsrp >= RSRP_THRESHOLDS.FAIR) return 'fair';
if (rsrp >= RSRP_THRESHOLDS.POOR) return 'poor';
if (rsrp >= RSRP_THRESHOLDS.WEAK) return 'weak';
return 'no-service';
}
export const SIGNAL_COLORS = {
excellent: '#22c55e', // green-600
good: '#84cc16', // lime-500
fair: '#eab308', // yellow-500
poor: '#f97316', // orange-500
weak: '#ef4444', // red-500
} as const;
export function getRSRPColor(rsrp: number): string {
return SIGNAL_COLORS[getSignalQuality(rsrp)];
}
```
---
## User Interface Design
### Main Application Layout
```
┌─────────────────────────────────────────────────────────┐
│ RFCP - RF Coverage Planner 🌐 UA 👤 Олег ⚙️ │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ 🗺️ Interactive Map │ │
│ │ (Leaflet + OSM) │ │
│ │ │ │
│ │ Click to place base station │ │
│ │ │ │
│ │ [Heatmap overlay when calculated] │ │
│ │ │ │
│ │ │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ 📍 Sites (3) [+ Add]│
│ ┌───────────────────────────────────────────────────┐ │
│ │ 🔴 Station-1 1800 MHz 43 dBm [Edit] [×] │ │
│ │ 🔵 Station-2 2600 MHz 30 dBm [Edit] [×] │ │
│ │ 🟢 Station-3 800 MHz 46 dBm [Edit] [×] │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ [🧮 Розрахувати покриття] [📥 Завантажити terrain] │
│ │
└─────────────────────────────────────────────────────────┘
```
### Site Configuration Panel (Manual Input)
```
┌─────────────────────────────────────────────────────┐
│ 📡 Конфігурація базової станції │
├─────────────────────────────────────────────────────┤
│ │
│ 📍 Назва станції │
│ [Station-1 ] │
│ │
│ 📍 Координати │
│ Lat: [48.4647] Lon: [35.0462] [📍 Змінити] │
│ │
│ ━━━━━━━━━━━━ Радіопараметри ━━━━━━━━━━━━ │
│ │
│ ⚡ Потужність передавача (dBm) │
│ [════════●═══════════════════════] 43 │
│ 10 50 │
│ 💡 Типові: LimeSDR 20, BBU 43, RRU 46 │
│ │
│ 📶 Коефіцієнт підсилення антени (dBi) │
│ [══════●═════════════════════════] 8 │
│ 0 25 │
│ 💡 Omni 2-8, Sector 15-18, Parabolic 20-25 │
│ │
│ 📻 Робоча частота │
│ ┌─────────────────────────────────────────────┐ │
│ │ [800] [1800] [1900] [2600] або: │ │
│ │ [1800] MHz │ │
│ │ Band 3 (1710-1880 MHz) │ │
│ │ λ = 16.7 cm • Medium range • Good penetr. │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ━━━━━━━━━━━ Фізичні параметри ━━━━━━━━━━━ │
│ │
│ 📏 Висота підвісу антени (метрів) │
│ [══════════●═════════════════════] 30 │
│ 1 100 │
│ 💡 Висота від землі до центру антени │
│ │
│ 📡 Тип антени │
│ ◉ Всенаправлена (Omni) │
│ ○ Секторна (Sector) │
│ │
│ ┌─ [Sector parameters (collapsed)] ────────────┐ │
│ │ 📐 Азимут (градуси) [0] │ │
│ │ 📊 Ширина променя (градуси) [65] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ━━━━━━━━━━━━━ Примітки ━━━━━━━━━━━━━━ │
│ │
│ 📝 Обладнання / Примітки (необов'язково) │
│ [ZTE B8200 BBU + custom omni antenna ] │
│ [Test deployment, field validation needed ] │
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ 💾 Quick Templates (optional) │
│ [🚀 LimeSDR] [📡 Low BBU] [📡 High BBU] │
│ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ [💾 Зберегти зміни] [🗑️ Видалити станцію] │
│ │
└─────────────────────────────────────────────────────┘
```
**Key UI Principles:**
-**Sliders with values** - визуальний feedback + точний контроль
-**Helpful hints** - типові значення під полями
-**Contextual info** - band info, wavelength automatically shown
-**Progressive disclosure** - sector params hidden if omni selected
-**Optional templates** - швидкий старт, але не обов'язково
-**Free-text notes** - користувач описує свій setup
---
## Terrain Data Management (30m SRTM)
### Ukraine Terrain Regions (Updated for 30m)
```typescript
// terrain/regions.ts
export const UKRAINE_REGIONS = {
'ukraine-west': {
name: 'Західна Україна',
nameEn: 'Western Ukraine',
bounds: {
north: 52.5,
south: 47.9,
west: 22.1,
east: 27.0
},
resolution: 30, // meters (1 arc-second)
size: '~400MB', // compressed
includes: ['Львів', 'Івано-Франківськ', 'Чернівці', 'Тернопіль', 'Луцьк']
},
'ukraine-center': {
name: 'Центральна Україна',
nameEn: 'Central Ukraine',
bounds: {
north: 52.5,
south: 48.0,
west: 27.0,
east: 32.0
},
resolution: 30,
size: '~400MB',
includes: ['Київ', 'Вінниця', 'Житомир', 'Черкаси', 'Полтава']
},
'ukraine-east': {
name: 'Східна Україна',
nameEn: 'Eastern Ukraine',
bounds: {
north: 50.5,
south: 47.0,
west: 32.0,
east: 40.3
},
resolution: 30,
size: '~400MB',
includes: ['Харків', 'Донецьк', 'Луганськ', 'Дніпро', 'Кривий Ріг']
},
'ukraine-south': {
name: 'Південна Україна',
nameEn: 'Southern Ukraine',
bounds: {
north: 48.0,
south: 44.4,
west: 27.0,
east: 38.0
},
resolution: 30,
size: '~400MB',
includes: ['Одеса', 'Миколаїв', 'Херсон', 'Запоріжжя', 'Мелітополь']
},
'ukraine-north': {
name: 'Північна Україна',
nameEn: 'Northern Ukraine',
bounds: {
north: 52.4,
south: 50.4,
west: 27.0,
east: 35.0
},
resolution: 30,
size: '~300MB',
includes: ['Чернігів', 'Суми', 'Рівне', 'Жмеринка']
}
} as const;
export type UkraineRegion = keyof typeof UKRAINE_REGIONS;
/**
* Determine which region contains the given coordinates
*/
export function latLonToRegion(lat: number, lon: number): UkraineRegion | null {
for (const [region, data] of Object.entries(UKRAINE_REGIONS)) {
const { north, south, west, east } = data.bounds;
if (lat >= south && lat <= north && lon >= west && lon <= east) {
return region as UkraineRegion;
}
}
return null;
}
/**
* Get total size for typical 2-3 region deployment
*/
export function getTypicalDeploymentSize(): string {
// Most users will download 2-3 regions
// ukraine-east (400MB) + ukraine-south (400MB) + ukraine-center (400MB)
return '~800MB - 1.2GB for 2-3 regions';
}
```
### Enhanced Terrain Manager (30m resolution)
```typescript
// terrain/manager.ts
export class TerrainManager {
private cache = new Map<string, TerrainTile>();
private downloading = new Map<string, Promise<TerrainTile>>();
constructor(private db: RFCPDatabase) {}
/**
* Get elevation at specific coordinates
* Uses 30m SRTM data with bilinear interpolation
* Auto-downloads terrain if needed and online
*/
async getElevation(lat: number, lon: number): Promise<number> {
const region = latLonToRegion(lat, lon);
if (!region) {
console.warn('Coordinates outside Ukraine bounds');
return 0;
}
// Check memory cache
if (this.cache.has(region)) {
return this.interpolateElevation(this.cache.get(region)!, lat, lon);
}
// Check IndexedDB cache
const cached = await this.db.terrain.get(region);
if (cached) {
const tile = this.deserializeTile(cached);
this.cache.set(region, tile);
return this.interpolateElevation(tile, lat, lon);
}
// Download if online
if (navigator.onLine) {
console.log(`Auto-downloading terrain for ${region}`);
await this.downloadRegion(region);
return this.getElevation(lat, lon); // Recursive call after download
}
console.warn(`Terrain data for ${region} not available offline`);
return 0;
}
/**
* Download 30m SRTM terrain data for region
* ~400MB per region
*/
async downloadRegion(region: UkraineRegion): Promise<void> {
// Prevent duplicate downloads
if (this.downloading.has(region)) {
await this.downloading.get(region);
return;
}
const promise = this._downloadRegion(region);
this.downloading.set(region, promise);
try {
await promise;
} finally {
this.downloading.delete(region);
}
}
private async _downloadRegion(region: UkraineRegion): Promise<TerrainTile> {
const regionData = UKRAINE_REGIONS[region];
console.log(`Downloading 30m terrain: ${regionData.name} (~${regionData.size})`);
const startTime = Date.now();
const response = await fetch(`/api/terrain/${region}`);
if (!response.ok) {
throw new Error(`Failed to download terrain: ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const tile = this.parseSRTM30m(buffer, region);
const downloadTime = Date.now() - startTime;
console.log(
`Terrain downloaded: ${regionData.name} ` +
`(${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB in ${(downloadTime / 1000).toFixed(1)}s)`
);
// Save to IndexedDB
await this.db.terrain.put({
region,
bounds: regionData.bounds,
resolution: 30,
data: buffer,
width: tile.width,
height: tile.height,
downloadedAt: Date.now(),
sizeBytes: buffer.byteLength
});
// Cache in memory
this.cache.set(region, tile);
return tile;
}
/**
* Parse 30m SRTM binary data (1 arc-second)
*/
private parseSRTM30m(buffer: ArrayBuffer, region: UkraineRegion): TerrainTile {
const bounds = UKRAINE_REGIONS[region].bounds;
const resolution = 1; // 1 arc-second (~30m)
// Calculate tile dimensions
// 1 degree = 3600 arc-seconds
// With 1 arc-second resolution, we get 3600 pixels per degree
const width = Math.ceil((bounds.east - bounds.west) * 3600 / resolution);
const height = Math.ceil((bounds.north - bounds.south) * 3600 / resolution);
// Parse as 16-bit signed integers (big-endian)
// SRTM format: 2 bytes per sample
const data = new Int16Array(buffer);
// Handle byte order (SRTM is big-endian)
if (!this.isBigEndian()) {
for (let i = 0; i < data.length; i++) {
data[i] = ((data[i] & 0xFF) << 8) | ((data[i] >> 8) & 0xFF);
}
}
return {
region,
bounds,
resolution: 30, // meters
data,
width,
height,
downloadedAt: Date.now(),
sizeBytes: buffer.byteLength
};
}
/**
* Bilinear interpolation for smooth elevation lookup
* Essential for 30m data to get accurate intermediate values
*/
private interpolateElevation(
tile: TerrainTile,
lat: number,
lon: number
): number {
const { bounds, width, height, data } = tile;
// Convert lat/lon to pixel coordinates
const x = ((lon - bounds.west) / (bounds.east - bounds.west)) * (width - 1);
const y = ((bounds.north - lat) / (bounds.north - bounds.south)) * (height - 1);
// Get surrounding pixel indices
const x0 = Math.floor(x);
const x1 = Math.min(x0 + 1, width - 1);
const y0 = Math.floor(y);
const y1 = Math.min(y0 + 1, height - 1);
// Interpolation weights
const dx = x - x0;
const dy = y - y0;
// Get elevation at 4 corners
const e00 = data[y0 * width + x0];
const e10 = data[y0 * width + x1];
const e01 = data[y1 * width + x0];
const e11 = data[y1 * width + x1];
// Handle void data (SRTM uses -32768 for no data)
if (e00 === -32768 || e10 === -32768 || e01 === -32768 || e11 === -32768) {
return 0; // Default to sea level for void areas
}
// Bilinear interpolation
const e0 = e00 * (1 - dx) + e10 * dx;
const e1 = e01 * (1 - dx) + e11 * dx;
return e0 * (1 - dy) + e1 * dy;
}
/**
* Get terrain profile between two points
* Useful for line-of-sight analysis
*/
async getTerrainProfile(
lat1: number, lon1: number,
lat2: number, lon2: number,
numSamples: number = 50
): Promise<number[]> {
const profile: number[] = [];
for (let i = 0; i <= numSamples; i++) {
const ratio = i / numSamples;
const lat = lat1 + (lat2 - lat1) * ratio;
const lon = lon1 + (lon2 - lon1) * ratio;
const elevation = await this.getElevation(lat, lon);
profile.push(elevation);
}
return profile;
}
/**
* Get download status for all regions
*/
async getDownloadStatus(): Promise<Record<UkraineRegion, boolean>> {
const status: any = {};
for (const region of Object.keys(UKRAINE_REGIONS) as UkraineRegion[]) {
const cached = await this.db.terrain.get(region);
status[region] = !!cached;
}
return status;
}
/**
* Get total storage used by terrain data
*/
async getStorageUsed(): Promise<number> {
const tiles = await this.db.terrain.toArray();
return tiles.reduce((sum, tile) => sum + tile.sizeBytes, 0);
}
/**
* Clear terrain cache (free up space)
*/
async clearCache(region?: UkraineRegion): Promise<void> {
if (region) {
await this.db.terrain.delete(region);
this.cache.delete(region);
} else {
await this.db.terrain.clear();
this.cache.clear();
}
}
private isBigEndian(): boolean {
const buffer = new ArrayBuffer(2);
new DataView(buffer).setInt16(0, 256, true);
return new Int16Array(buffer)[0] !== 256;
}
}
```
---
## RF Calculation Engine (Universal)
### Core Calculator Class
```typescript
// rf/calculator.ts
export class RFCalculator {
private terrain: TerrainManager;
constructor(terrain: TerrainManager) {
this.terrain = terrain;
}
/**
* Calculate coverage for multiple sites
* Works with any user-provided parameters (LTE, UHF, VHF, etc.)
* Uses Web Workers for parallel processing
*/
async calculateCoverage(
sites: Site[],
bounds: LatLngBounds,
settings: CoverageSettings
): Promise<CoverageResult> {
const startTime = performance.now();
// Generate grid of points to calculate
const gridPoints = this.generateGrid(bounds, settings.resolution);
console.log(
`Calculating coverage for ${sites.length} sites, ` +
`${gridPoints.length} points (${settings.resolution}m resolution)`
);
// Split work across multiple workers (4 parallel threads)
const numWorkers = Math.min(4, navigator.hardwareConcurrency || 4);
const workers = this.createWorkers(numWorkers);
const chunks = this.chunkArray(gridPoints, numWorkers);
// Calculate coverage in parallel
const results = await Promise.all(
chunks.map((chunk, i) =>
this.calculateChunk(
workers[i],
sites,
chunk,
settings.rsrpThreshold
)
)
);
// Merge results from all workers
const allPoints = results.flat();
// Cleanup workers
workers.forEach(w => w.terminate());
const calculationTime = performance.now() - startTime;
console.log(
`Coverage calculated: ${allPoints.length} points in ` +
`${(calculationTime / 1000).toFixed(2)}s`
);
return {
points: allPoints,
calculationTime,
totalPoints: allPoints.length,
bounds,
settings
};
}
/**
* Generate grid of lat/lon points
*/
private generateGrid(
bounds: LatLngBounds,
resolution: number // meters
): GridPoint[] {
const points: GridPoint[] = [];
// Convert resolution to degrees (approximate)
const latStep = resolution / 111000; // ~111km per degree latitude
const center = bounds.getCenter();
const lonStep = resolution / (111000 * Math.cos(center.lat * Math.PI / 180));
let lat = bounds.getSouth();
while (lat <= bounds.getNorth()) {
let lon = bounds.getWest();
while (lon <= bounds.getEast()) {
points.push({ lat, lon });
lon += lonStep;
}
lat += latStep;
}
return points;
}
/**
* Create Web Workers for parallel calculation
*/
private createWorkers(count: number): Worker[] {
const workers: Worker[] = [];
for (let i = 0; i < count; i++) {
workers.push(new Worker('/workers/rf-worker.js'));
}
return workers;
}
/**
* Split array into chunks for parallel processing
*/
private chunkArray<T>(array: T[], numChunks: number): T[][] {
const chunks: T[][] = [];
const chunkSize = Math.ceil(array.length / numChunks);
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Calculate coverage for a chunk of points (worker task)
*/
private async calculateChunk(
worker: Worker,
sites: Site[],
points: GridPoint[],
rsrpThreshold: number
): Promise<CoveragePoint[]> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Worker timeout'));
}, 30000); // 30 second timeout
worker.postMessage({
type: 'calculate',
sites,
points,
rsrpThreshold
});
worker.onmessage = (e) => {
clearTimeout(timeout);
if (e.data.type === 'complete') {
resolve(e.data.results);
} else if (e.data.type === 'error') {
reject(new Error(e.data.message));
}
};
worker.onerror = (err) => {
clearTimeout(timeout);
reject(err);
};
});
}
}
```
### Web Worker Implementation
```typescript
// workers/rf-worker.ts
// This runs in a separate thread for parallel processing
self.onmessage = async (e) => {
const { type, sites, points, rsrpThreshold } = e.data;
if (type === 'calculate') {
try {
const results: CoveragePoint[] = [];
// Process each point in this chunk
for (const point of points) {
let bestRSRP = -Infinity;
let bestSiteId = '';
// Find best serving site for this point
for (const site of sites) {
const rsrp = calculatePointRSRP(site, point);
if (rsrp > bestRSRP) {
bestRSRP = rsrp;
bestSiteId = site.id;
}
}
// Only include points above threshold
if (bestRSRP >= rsrpThreshold) {
results.push({
lat: point.lat,
lon: point.lon,
rsrp: bestRSRP,
siteId: bestSiteId
});
}
}
self.postMessage({ type: 'complete', results });
} catch (error) {
self.postMessage({
type: 'error',
message: error.message
});
}
}
};
/**
* Calculate RSRP at a specific point
* Universal formula - works with any frequency
*/
function calculatePointRSRP(site: any, point: any): number {
const distance = haversineDistance(
site.lat, site.lon,
point.lat, point.lon
);
// Free space path loss (universal)
const fspl = 20 * Math.log10(distance)
+ 20 * Math.log10(site.frequency)
+ 32.45;
// Link budget
let rsrp = site.power + site.gain - fspl;
// TODO: Add terrain loss (requires terrain data in worker)
// For MVP, terrain loss will be added in Phase 4
// Apply sector antenna pattern
if (site.antennaType === 'sector' && site.azimuth !== undefined) {
const bearing = calculateBearing(
site.lat, site.lon,
point.lat, point.lon
);
const relativeAngle = Math.abs(bearing - site.azimuth);
const normalizedAngle = relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
const patternLoss = calculateSectorPatternLoss(
normalizedAngle,
site.beamwidth || 65
);
rsrp -= patternLoss;
}
return rsrp;
}
/**
* Haversine distance formula
*/
function haversineDistance(
lat1: number, lon1: number,
lat2: number, lon2: number
): number {
const R = 6371; // Earth radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) *
Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
/**
* Calculate bearing from point A to point B
*/
function calculateBearing(
lat1: number, lon1: number,
lat2: number, lon2: number
): number {
const dLon = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2 * Math.PI / 180);
const x = Math.cos(lat1 * Math.PI / 180) * Math.sin(lat2 * Math.PI / 180) -
Math.sin(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.cos(dLon);
const bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
}
/**
* Sector antenna pattern loss
*/
function calculateSectorPatternLoss(
angleOffBoresight: number,
beamwidth: number
): number {
const theta3dB = beamwidth / 2;
const sideLobeLevel = 20;
if (angleOffBoresight <= theta3dB) {
return 12 * Math.pow(angleOffBoresight / theta3dB, 2);
}
return Math.min(
12 * Math.pow(angleOffBoresight / theta3dB, 2),
sideLobeLevel
);
}
```
---
## Development Roadmap
### Phase 1: Core UI & Manual Input (Days 1-2)
**Goal:** Working PWA with map and flexible input UI
**Tasks:**
- [ ] Initialize React + TypeScript + Vite project
- [ ] Configure Tailwind CSS
- [ ] Setup Workbox for Service Worker
- [ ] Create PWA manifest.json
- [ ] Implement IndexedDB schema (Dexie)
- [ ] Setup Leaflet map component
- [ ] Add OpenStreetMap tile layer
- [ ] Implement click-to-place site marker
- [ ] Create manual input form with:
- [ ] Power slider (10-50 dBm)
- [ ] Gain slider (0-25 dBi)
- [ ] Dynamic frequency input (quick buttons + custom)
- [ ] Height slider (1-100m)
- [ ] Antenna type selector (omni/sector)
- [ ] Sector parameters (azimuth, beamwidth)
- [ ] Free-text notes field
- [ ] Implement frequency band info display
- [ ] Add wavelength calculator
- [ ] Create optional quick templates
- [ ] Setup Zustand state management
- [ ] Add site list panel (view/edit/delete)
**Deliverables:**
- ✅ Working PWA that can be installed
- ✅ Map with OSM tiles (cached offline)
- ✅ Can place multiple site markers
- ✅ Fully flexible manual input form
- ✅ Contextual frequency information
**Testing:**
```bash
npm run dev
# Open http://localhost:5173
# Click on map → marker appears
# Configure site parameters manually
# Add multiple sites
# Verify all inputs work
```
---
### Phase 2: RF Calculation Engine (Days 3-4)
**Goal:** Working RF calculations in browser (universal)
**Tasks:**
- [ ] Implement universal FSPL calculator
- [ ] Create haversine distance function
- [ ] Implement bearing calculator
- [ ] Create grid generator
- [ ] Implement sector antenna pattern calculator
- [ ] Create Web Worker template
- [ ] Implement RFCalculator class
- [ ] Add multi-site coverage aggregation
- [ ] Create signal quality color mapping
- [ ] Add calculation progress indicator
- [ ] Unit tests for RF functions
- [ ] Performance optimization (target < 3s for 3 sites)
**Deliverables:**
- ✅ Can calculate coverage for single site (any frequency)
- ✅ Can calculate composite coverage (multiple sites)
- ✅ Calculations run in Web Workers (non-blocking UI)
- ✅ Results ready for visualization
- ✅ Works with user's custom parameters
**Testing:**
```typescript
// Test universal FSPL
const fspl1800 = calculateFSPL(1.0, 1800);
const fspl150 = calculateFSPL(1.0, 150); // VHF
console.assert(fspl150 < fspl1800, 'Lower freq = less path loss');
// Test coverage calculation
const calculator = new RFCalculator(terrain);
const result = await calculator.calculateCoverage(
[customSite], // User's parameters
bounds,
{ radius: 5, resolution: 100, rsrpThreshold: -120 }
);
console.log(`Calculated ${result.points.length} points`);
```
---
### Phase 3: Heatmap Visualization (Days 5-6)
**Goal:** Beautiful coverage heatmap on map
**Tasks:**
- [ ] Integrate Leaflet.heat plugin
- [ ] Convert coverage points to heatmap format
- [ ] Implement color gradient mapping (RSRP → color)
- [ ] Add heatmap layer to map
- [ ] Create legend component with RSRP ranges
- [ ] Add toggle heatmap visibility
- [ ] Implement zoom-dependent detail levels
- [ ] Add loading spinner during calculation
- [ ] Optimize rendering for 5000+ points
- [ ] Add "Download as PNG" feature
**Deliverables:**
- ✅ Colorful heatmap overlay on map
- ✅ Legend showing RSRP ranges and colors
- ✅ Smooth performance with large datasets
- ✅ Toggle to show/hide heatmap
- ✅ Visual feedback during calculation
**Testing:**
```typescript
// Visual test
// 1. Place site with power=43 dBm, freq=1800 MHz
// 2. Calculate coverage
// 3. Should see green circle near site (~2km radius)
// 4. Yellow/orange further away (~3-5km)
// 5. Red at edges (~5-7km)
// 6. Toggle heatmap on/off smoothly
// 7. Performance: rendering < 2s for 3 sites
```
---
### Phase 4: Terrain Integration (Days 7-9)
**Goal:** Real 30m terrain data in calculations
**Tasks:**
- [ ] Implement enhanced TerrainManager class
- [ ] Create 30m SRTM parser (1 arc-second)
- [ ] Add byte-order handling (big-endian)
- [ ] Implement bilinear interpolation
- [ ] Add region detection (lat/lon → region)
- [ ] Implement terrain loss calculation
- [ ] Add terrain profile generation
- [ ] Create terrain download UI
- [ ] Implement auto-download on map pan
- [ ] Add download progress indicator (with size display)
- [ ] Store terrain in IndexedDB
- [ ] Handle offline terrain access
- [ ] Add storage management UI
- [ ] Test with real Ukraine terrain
**Deliverables:**
- ✅ Can download 30m terrain regions (~400MB each)
- ✅ Terrain data persists offline
- ✅ Elevation lookup works with bilinear interpolation
- ✅ Coverage calculations consider terrain (more accurate)
- ✅ Storage management (view usage, clear cache)
**Testing:**
```typescript
// Download terrain
const terrain = new TerrainManager(db);
await terrain.downloadRegion('ukraine-east'); // ~400MB, 30-60s
// Check elevation accuracy
const dnipro = await terrain.getElevation(48.4647, 35.0462);
console.log(`Dnipro elevation: ${dnipro}m`); // Should be ~50-70m
// Check storage
const used = await terrain.getStorageUsed();
console.log(`Storage used: ${(used / 1024 / 1024).toFixed(0)} MB`);
// Test offline
// Go offline
// Pan to Dnipro
// Should still get elevation data from IndexedDB
```
---
### Phase 5: Multi-Site & UX Polish (Days 10-11)
**Goal:** Production-ready MVP
**Tasks:**
- [ ] Support multiple sites on map (tested with 5+ sites)
- [ ] Add site list panel with thumbnails
- [ ] Implement site color coding
- [ ] Add calculation settings panel (radius, resolution)
- [ ] Implement site drag-to-reposition
- [ ] Add keyboard shortcuts (Delete, Esc, etc.)
- [ ] Create user guide overlay (first-time experience)
- [ ] Mobile responsive design
- [ ] Touch gestures for mobile (pinch-zoom, pan)
- [ ] Add error handling & toast notifications
- [ ] Implement undo/redo for site changes
- [ ] Add project save/load functionality
- [ ] Export coverage as PNG/GeoJSON
- [ ] Performance optimization
- [ ] Internationalization (Ukrainian/English)
**Deliverables:**
- ✅ Can manage 5+ sites easily
- ✅ Intuitive UI, works on mobile/tablet
- ✅ Polished user experience
- ✅ Helpful onboarding
- ✅ Bilingual support
---
### Phase 6: Backend & Auth (Days 12-13)
**Goal:** Optional cloud sync
**Tasks:**
- [ ] Create FastAPI backend project
- [ ] Implement terrain CDN endpoints
- [ ] Setup 30m SRTM data preprocessing
- [ ] Add MAS OAuth integration
- [ ] Create config sync API
- [ ] Implement JWT auth middleware
- [ ] Setup SQLite for config storage
- [ ] Create docker-compose.yml
- [ ] Configure Caddy reverse proxy
- [ ] Add background sync in Service Worker
- [ ] Test multi-device sync
**Deliverables:**
- ✅ Backend API running
- ✅ Can login with MAS
- ✅ Configs sync across devices
- ✅ Terrain downloads work via CDN
---
### Phase 7: Deployment (Days 14-15)
**Goal:** Live at rfcp.eliah.one
**Tasks:**
- [ ] Setup domain DNS (rfcp.eliah.one)
- [ ] Deploy backend to VPS-A
- [ ] Deploy frontend with Caddy
- [ ] Configure SSL/TLS
- [ ] Upload 30m SRTM terrain data
- [ ] Test from external network
- [ ] Test on mobile devices (iOS/Android)
- [ ] Create offline installer ZIP
- [ ] Write user documentation
- [ ] Setup monitoring (Uptime Kuma)
- [ ] Create backup procedures
- [ ] Performance testing
- [ ] Security audit
**Deliverables:**
- ✅ Live at https://rfcp.eliah.one
- ✅ Works on desktop/mobile/tablet
- ✅ Offline installer available (~200MB base + terrain)
- ✅ Documentation complete (Ukrainian/English)
- ✅ Monitoring active
---
## Post-MVP Roadmap
### v1.1 - UHF/VHF Support (Week 4-5) 🎯
**Goal:** Add tactical radio frequency planning
**New Features:**
- [ ] VHF frequencies (30-300 MHz)
- [ ] VHF Low (30-50 MHz) - long range
- [ ] VHF High (136-174 MHz) - tactical radio
- [ ] FM broadcast (88-108 MHz)
- [ ] UHF frequencies (300-3000 MHz)
- [ ] UHF (400-470 MHz) - military tactical
- [ ] PMR446 (446 MHz) - personal radio
- [ ] Amateur radio bands
- [ ] Enhanced propagation models:
- [ ] Longley-Rice (VHF/UHF specific)
- [ ] ITU-R P.1546 (broadcasting)
- [ ] Frequency-specific characteristics:
- [ ] Ground wave propagation (VHF low)
- [ ] Ionospheric reflection (HF)
- [ ] Tropospheric scatter
- [ ] New UI elements:
- [ ] "Radio Type" selector (LTE/VHF/UHF/Amateur)
- [ ] Band-specific presets
- [ ] Propagation mode selector
**Technical Implementation:**
```typescript
// Extended frequency support
export const RADIO_TYPES = {
'lte': {
name: 'LTE (4G)',
freqRange: [700, 2700],
propagationModel: 'FSPL + terrain'
},
'vhf': {
name: 'VHF Radio',
freqRange: [30, 300],
propagationModel: 'Longley-Rice'
},
'uhf': {
name: 'UHF Radio',
freqRange: [300, 3000],
propagationModel: 'Longley-Rice'
},
'amateur': {
name: 'Amateur Radio',
freqRange: [1.8, 30000], // HF to SHF
propagationModel: 'Mixed'
}
};
```
**Use Cases:**
- ✅ Planning tactical radio networks (VHF 150 MHz)
- ✅ UHF repeater placement (450 MHz)
- ✅ Amateur radio link planning
- ✅ Emergency services coordination
---
### v1.2 - Advanced Features (Week 6-7)
**Features:**
- [ ] Okumura-Hata propagation model (urban areas)
- [ ] COST 231 extension (dense urban)
- [ ] 3D sector antenna visualization
- [ ] Interference calculation between sites
- [ ] Best server boundary display
- [ ] Handover zone visualization
- [ ] Link budget calculator
- [ ] Export to KML/KMZ (Google Earth)
- [ ] PDF report generation
- [ ] CSV import/export
- [ ] Real RF measurements overlay
---
### v2.0 - Advanced Platform (Month 3+)
**Features:**
- [ ] 3D terrain visualization (Three.js)
- [ ] Drive test integration
- [ ] Network optimization algorithms
- [ ] Machine learning coverage prediction
- [ ] Multi-technology support (5G NR)
- [ ] Collaborative planning (WebRTC)
- [ ] Cost estimation
- [ ] Weather effects modeling
- [ ] Real-time spectrum monitoring
---
## File Structure
```
rfcp/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── frontend/
│ ├── public/
│ │ ├── icons/ # PWA icons
│ │ ├── workers/
│ │ │ └── rf-worker.js # RF calculation worker
│ │ ├── manifest.json # PWA manifest
│ │ ├── offline.html # Offline fallback
│ │ └── robots.txt
│ │
│ ├── src/
│ │ ├── components/
│ │ │ ├── map/
│ │ │ │ ├── Map.tsx # Main map component
│ │ │ │ ├── SiteMarker.tsx # Site marker
│ │ │ │ ├── Heatmap.tsx # Heatmap layer
│ │ │ │ └── Legend.tsx # Signal legend
│ │ │ ├── panels/
│ │ │ │ ├── SiteForm.tsx # Manual input form
│ │ │ │ ├── SiteList.tsx # Sites list
│ │ │ │ ├── Settings.tsx # Settings panel
│ │ │ │ ├── TerrainPanel.tsx # Terrain management
│ │ │ │ └── FrequencySelector.tsx # Dynamic freq input
│ │ │ └── ui/
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Slider.tsx
│ │ │ ├── Select.tsx
│ │ │ └── Toast.tsx
│ │ │
│ │ ├── rf/
│ │ │ ├── calculator.ts # Main RF calculator
│ │ │ ├── fspl.ts # Universal FSPL
│ │ │ ├── terrain-loss.ts # Terrain calculations
│ │ │ ├── antenna-pattern.ts # Sector patterns
│ │ │ └── utils.ts # Haversine, bearing, etc.
│ │ │
│ │ ├── terrain/
│ │ │ ├── manager.ts # TerrainManager (30m)
│ │ │ ├── regions.ts # Ukraine regions
│ │ │ ├── parser.ts # SRTM parser
│ │ │ └── interpolation.ts # Bilinear interpolation
│ │ │
│ │ ├── store/
│ │ │ ├── sites.ts # Sites state (Zustand)
│ │ │ ├── settings.ts # Settings state
│ │ │ ├── coverage.ts # Coverage state
│ │ │ └── auth.ts # Auth state
│ │ │
│ │ ├── db/
│ │ │ ├── schema.ts # IndexedDB schema
│ │ │ └── migrations.ts # DB migrations
│ │ │
│ │ ├── api/
│ │ │ ├── client.ts # API client
│ │ │ ├── terrain.ts # Terrain endpoints
│ │ │ ├── sync.ts # Sync endpoints
│ │ │ └── auth.ts # Auth endpoints
│ │ │
│ │ ├── types/
│ │ │ ├── site.ts
│ │ │ ├── coverage.ts
│ │ │ ├── terrain.ts
│ │ │ ├── frequency.ts
│ │ │ └── index.ts
│ │ │
│ │ ├── constants/
│ │ │ ├── frequencies.ts # Common bands
│ │ │ ├── rsrp-thresholds.ts
│ │ │ └── colors.ts
│ │ │
│ │ ├── utils/
│ │ │ ├── geo.ts # Geographic utilities
│ │ │ ├── colors.ts # Color utilities
│ │ │ ├── format.ts # Formatters
│ │ │ └── validation.ts # Input validation
│ │ │
│ │ ├── hooks/
│ │ │ ├── useRFCalculator.ts
│ │ │ ├── useTerrain.ts
│ │ │ ├── useOffline.ts
│ │ │ └── useFrequencyInfo.ts
│ │ │
│ │ ├── i18n/
│ │ │ ├── uk.json # Ukrainian
│ │ │ └── en.json # English
│ │ │
│ │ ├── App.tsx
│ │ ├── main.tsx
│ │ ├── service-worker.ts # Service Worker
│ │ └── vite-env.d.ts
│ │
│ ├── tests/
│ │ ├── unit/
│ │ │ ├── fspl.test.ts
│ │ │ ├── haversine.test.ts
│ │ │ ├── terrain.test.ts
│ │ │ └── antenna-pattern.test.ts
│ │ └── integration/
│ │ ├── calculator.test.ts
│ │ └── terrain-manager.test.ts
│ │
│ ├── .gitignore
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── tailwind.config.js
│ └── README.md
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI app
│ │ ├── config.py # Configuration
│ │ │
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── terrain.py # Terrain CDN endpoints
│ │ │ ├── sync.py # Sync endpoints
│ │ │ └── auth.py # MAS OAuth
│ │ │
│ │ ├── services/
│ │ │ ├── terrain.py # Terrain service
│ │ │ ├── sync.py # Sync service
│ │ │ └── auth.py # Auth service
│ │ │
│ │ ├── models/
│ │ │ └── config.py # Pydantic models
│ │ │
│ │ └── db/
│ │ └── database.py # SQLite setup
│ │
│ ├── data/
│ │ └── terrain/ # 30m SRTM files
│ │ ├── ukraine-west.hgt
│ │ ├── ukraine-center.hgt
│ │ ├── ukraine-east.hgt
│ │ ├── ukraine-south.hgt
│ │ └── ukraine-north.hgt
│ │
│ ├── scripts/
│ │ ├── download-srtm.py # Download SRTM data
│ │ └── preprocess-terrain.py # Preprocess for web
│ │
│ ├── tests/
│ │ ├── test_terrain.py
│ │ └── test_sync.py
│ │
│ ├── .gitignore
│ ├── requirements.txt
│ ├── Dockerfile
│ └── README.md
├── docs/
│ ├── README.md # Project overview
│ ├── USAGE-uk.md # User guide (Ukrainian)
│ ├── USAGE-en.md # User guide (English)
│ ├── API.md # API documentation
│ └── DEPLOYMENT.md # Deployment guide
├── docker-compose.yml
├── Caddyfile
├── .gitignore
├── LICENSE
└── RFCP-TechSpec-v3.0.md # This file
```
---
## Performance Targets
### Load Time
- Initial page load (online): < 3 seconds
- Initial page load (offline, cached): < 1 second
- Service Worker registration: < 500ms
- PWA installation prompt: < 2 seconds after load
### Calculation Performance
- Single site, 5km radius, 100m resolution: < 1 second
- Three sites, 5km radius, 100m resolution: < 2.5 seconds
- Five sites, 10km radius, 100m resolution: < 8 seconds
- With 30m terrain data: +20% calculation time (acceptable)
### Rendering Performance
- Heatmap render (1000 points): < 500ms
- Heatmap render (5000 points): < 1.5 seconds
- Heatmap render (10000 points): < 3 seconds
- Map pan/zoom: 60 FPS (smooth)
### Memory Usage
- Base app: < 100MB
- With 3 sites calculated: < 200MB
- With 30m terrain loaded (1 region): < 600MB
- With 30m terrain loaded (3 regions): < 1.5GB
### Storage
- Base app cache: ~15MB
- 30m terrain per region: ~400MB
- User projects: < 1MB each
- Total max storage: ~1.5GB (for 3 regions)
### Network
- Terrain download speed: depends on connection (30-60s for 400MB on 4G)
- API response time: < 500ms
- OAuth flow: < 2 seconds
---
## Security & Privacy
### PWA Security
- Service Worker scope limited to origin
- Content Security Policy (CSP) headers
- No eval() or inline scripts
- HTTPS required for production
- Subresource Integrity (SRI) for CDN assets
### Data Privacy
- All calculations client-side (no data sent to server)
- Terrain data cached locally
- Optional cloud sync requires authentication
- User projects encrypted in IndexedDB (future)
- No analytics or tracking
### Authentication
- OAuth 2.0 with MAS
- JWT tokens with short expiry (1 hour)
- Refresh token rotation
- No password storage
- Logout clears all tokens
### Network Security
- TLS 1.3 required
- HSTS enabled
- Certificate pinning (future)
- Rate limiting on API (100 req/hour)
- CORS properly configured
---
## Testing Strategy
### Unit Tests
```typescript
// tests/unit/fspl.test.ts
import { describe, it, expect } from 'vitest';
import { calculateFSPL } from '@/rf/fspl';
describe('Universal FSPL Calculator', () => {
it('calculates correct path loss at 1km, 1800MHz', () => {
const fspl = calculateFSPL(1.0, 1800);
expect(fspl).toBeCloseTo(92.5, 1);
});
it('calculates correct path loss for VHF (150 MHz)', () => {
const fspl = calculateFSPL(1.0, 150);
expect(fspl).toBeCloseTo(71.0, 1);
});
it('lower frequency = less path loss', () => {
const fspl150 = calculateFSPL(5.0, 150);
const fspl1800 = calculateFSPL(5.0, 1800);
expect(fspl150).toBeLessThan(fspl1800);
});
it('handles zero distance', () => {
const fspl = calculateFSPL(0, 1800);
expect(fspl).toBe(0);
});
});
```
### Integration Tests
```typescript
// tests/integration/calculator.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { RFCalculator } from '@/rf/calculator';
import { TerrainManager } from '@/terrain/manager';
describe('RF Calculator Integration', () => {
let calculator: RFCalculator;
beforeAll(() => {
const terrain = new TerrainManager(db);
calculator = new RFCalculator(terrain);
});
it('calculates coverage for single LTE site', async () => {
const lteSite: Site = {
id: '1',
name: 'Test LTE',
lat: 48.0,
lon: 35.0,
height: 30,
power: 43,
gain: 8,
frequency: 1800,
antennaType: 'omni',
// ... other fields
};
const result = await calculator.calculateCoverage(
[lteSite],
testBounds,
{ radius: 5, resolution: 100, rsrpThreshold: -120 }
);
expect(result.points.length).toBeGreaterThan(0);
expect(result.calculationTime).toBeLessThan(2000);
});
it('calculates coverage for custom frequency', async () => {
const customSite: Site = {
// ... LTE site params
frequency: 450, // UHF
};
const result = await calculator.calculateCoverage([customSite], bounds, settings);
expect(result.points.length).toBeGreaterThan(0);
});
});
```
### E2E Tests
```typescript
// tests/e2e/coverage-flow.spec.ts
import { test, expect } from '@playwright/test';
test('complete coverage calculation flow with manual input', async ({ page }) => {
await page.goto('http://localhost:5173');
await page.waitForSelector('.leaflet-container');
// Click on map to place site
await page.click('.leaflet-container', {
position: { x: 400, y: 300 }
});
// Fill in manual parameters
await page.fill('input[name="name"]', 'Test Station');
await page.getByLabel('Потужність передавача').fill('43');
await page.getByLabel('Коефіцієнт антени').fill('8');
// Select frequency
await page.click('button:has-text("1800")');
await page.getByLabel('Висота антени').fill('30');
// Save site
await page.click('button:has-text("Зберегти")');
// Calculate coverage
await page.click('button:has-text("Розрахувати покриття")');
// Wait for heatmap
await page.waitForSelector('.leaflet-heatmap-layer', {
timeout: 10000
});
// Verify heatmap is visible
const heatmap = await page.locator('.leaflet-heatmap-layer');
await expect(heatmap).toBeVisible();
});
```
---
## Deployment
### Prerequisites
```bash
# VPS requirements
- Ubuntu 22.04 LTS
- 4GB RAM (for terrain processing)
- 30GB disk space (20GB for terrain data)
- Docker + Docker Compose installed
- Domain configured (rfcp.eliah.one)
```
### Deployment Steps
```bash
# 1. Clone repository
git clone https://git.eliah.one/mytec/rfcp.git
cd rfcp
# 2. Configure environment
cp .env.example .env
nano .env
# 3. Download and preprocess 30m SRTM terrain data
cd backend
python scripts/download-srtm.py # Downloads raw SRTM
python scripts/preprocess-terrain.py # Converts to optimized format
cd ..
# 4. Build containers
docker-compose build
# 5. Start services
docker-compose up -d
# 6. Check status
docker-compose ps
curl https://rfcp.eliah.one/health
curl https://rfcp.eliah.one/api/terrain/ukraine-east # Should return 400MB file
```
### Docker Compose
```yaml
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
environment:
- NODE_ENV=production
- VITE_API_URL=https://rfcp.eliah.one/api
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
- ENVIRONMENT=production
- MAS_URL=https://mas.umtc.dev
- DATABASE_URL=sqlite:///data/rfcp.db
volumes:
- ./backend/data:/app/data
- terrain_data:/app/data/terrain
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
terrain_data:
```
### Caddyfile
```
rfcp.eliah.one {
# Frontend
reverse_proxy frontend:5173
# Backend API
reverse_proxy /api/* backend:8000
# Security headers
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.openstreetmap.org; worker-src 'self' blob:; connect-src 'self' https://rfcp.eliah.one"
}
# Cache static assets
@static {
path *.js *.css *.woff2 *.png *.jpg *.svg
}
header @static Cache-Control "public, max-age=31536000"
# Service Worker - no cache
@sw {
path /service-worker.js
}
header @sw Cache-Control "no-cache"
# Terrain data - long cache
@terrain {
path /api/terrain/*
}
header @terrain Cache-Control "public, max-age=7776000" # 90 days
}
```
---
## Success Metrics
### MVP Success Criteria
**Technical:**
- PWA installable on mobile/desktop/tablet
- Works offline after initial load
- Coverage calculation < 3 seconds (3 sites, 5km, 100m)
- Heatmap renders smoothly (< 2s for 5000 points)
- Supports 5+ sites simultaneously
- 30m terrain integration working
- Manual input fully functional
**User Experience:**
- Intuitive UI (no tutorial needed for basic tasks)
- Works on 4G tablet
- Terrain data downloads automatically
- Can save and load projects
- Bilingual support (UK/EN)
- Helpful contextual info (band info, wavelength)
**Deployment:**
- Live at https://rfcp.eliah.one
- 99% uptime
- Accessible from military networks
- Offline installer available (~200MB + terrain)
### KPIs
- **Performance:** 95% of calculations complete in < 3s
- **Adoption:** 20+ active users in first month
- **Reliability:** < 1 critical bug per week post-launch
- **Offline Usage:** 60%+ of sessions offline
- **Terrain Downloads:** Average 2-3 regions per user
---
## Open Questions & Next Steps
### Questions Answered ✅
1.**Terrain detail:** 30m SRTM (1 arc-second)
2.**Frequency bands:** Band 3, 7, 20, 2 + dynamic input (any 400-6000 MHz)
3.**Input method:** Manual input > equipment presets
4.**Equipment parameters:** Not critical for MVP, add later
5.**RF measurements:** v1.1 feature (CSV import)
6.**Languages:** Ukrainian + English only
### Next Actions
1. **Initialize Project**
- [x] Git repo created
- [x] TechSpec finalized
- [ ] Start Phase 1 implementation
2. **Phase 1 Start** (Tomorrow)
- [ ] Setup React + TypeScript + Vite
- [ ] Configure Tailwind
- [ ] Create manual input UI
- [ ] Integrate Leaflet map
3. **Ongoing**
- [ ] Weekly progress reviews
- [ ] Adjust roadmap based on feedback
- [ ] Test on real devices (tablets)
---
## Contact & Support
**Project Lead:** Олег (mytec)
**Repository:** https://git.eliah.one/mytec/rfcp
**Documentation:** https://git.eliah.one/mytec/rfcp/wiki
**Issues:** https://git.eliah.one/mytec/rfcp/issues
**Deployment:** https://rfcp.eliah.one
---
**Document Version:** 3.0 (Final)
**Last Updated:** 2025-01-30
**Status:** 🚀 Ready for Development
**Next Action:** Phase 1 Implementation - Manual Input UI
**Changes from v2.0:**
- ✅ 30m SRTM resolution (vs 90m)
- ✅ Manual input UI (vs equipment presets)
- ✅ Dynamic frequency input (vs fixed dropdown)
- ✅ Removed equipment database
- ✅ Added UHF/VHF roadmap (v1.1)
- ✅ Enhanced terrain manager for 30m data
- ✅ Updated UI mockups for manual input
- ✅ Clarified typical deployment (2-3 regions)