72 KiB
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)
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)
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
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)
// 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)
// 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)
// 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
/**
* 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)
/**
* 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
/**
* 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
// 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)
// 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)
// 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
// 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
// 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:
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:
// 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:
// 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:
// 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:
// 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
// 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
// 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
// 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
# 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
# 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
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 ✅
- ✅ Terrain detail: 30m SRTM (1 arc-second)
- ✅ Frequency bands: Band 3, 7, 20, 2 + dynamic input (any 400-6000 MHz)
- ✅ Input method: Manual input > equipment presets
- ✅ Equipment parameters: Not critical for MVP, add later
- ✅ RF measurements: v1.1 feature (CSV import)
- ✅ Languages: Ukrainian + English only
Next Actions
-
Initialize Project ✅
- Git repo created
- TechSpec finalized
- Start Phase 1 implementation
-
Phase 1 Start (Tomorrow)
- Setup React + TypeScript + Vite
- Configure Tailwind
- Create manual input UI
- Integrate Leaflet map
-
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)