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