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

72 KiB
Raw Blame History

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

  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

    • Git repo created
    • 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)