@mytec: feat: Phase 3.0 Architecture Refactor

Major refactoring of RFCP backend:
- Modular propagation models (8 models)
- SharedMemoryManager for terrain data
- ProcessPoolExecutor parallel processing
- WebSocket progress streaming
- Building filtering pipeline (351k → 15k)
- 82 unit tests

Performance: Standard preset 38s → 5s (7.6x speedup)

Known issue: Detailed preset timeout (fix in 3.1.0)
This commit is contained in:
2026-02-01 23:12:26 +02:00
parent 1dde56705a
commit defa3ad440
71 changed files with 7134 additions and 256 deletions

View File

@@ -29,6 +29,7 @@ import NumberInput from '@/components/ui/NumberInput.tsx';
import ConfirmDialog from '@/components/ui/ConfirmDialog.tsx';
import { RegionWizard } from '@/components/RegionWizard.tsx';
import { isDesktop, getDesktopApi } from '@/lib/desktop.ts';
import { wsService } from '@/services/websocket.ts';
import type { RegionInfo, CacheStats } from '@/services/api.ts';
/**
@@ -61,6 +62,7 @@ export default function App() {
const settings = useCoverageStore((s) => s.settings);
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
const coverageError = useCoverageStore((s) => s.error);
const coverageProgress = useCoverageStore((s) => s.progress);
const calculateCoverageApi = useCoverageStore((s) => s.calculateCoverage);
const cancelCalculation = useCoverageStore((s) => s.cancelCalculation);
@@ -89,6 +91,12 @@ export default function App() {
});
}, []);
// Connect WebSocket for real-time coverage progress
useEffect(() => {
wsService.connect();
return () => wsService.disconnect();
}, []);
const addToast = useToastStore((s) => s.addToast);
const showTerrain = useSettingsStore((s) => s.showTerrain);
@@ -504,7 +512,9 @@ export default function App() {
>
<span className="flex items-center gap-1">
<span className="animate-spin inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full" />
Cancel{elapsed > 0 ? ` (${elapsed}s)` : '...'}
{coverageProgress
? `${coverageProgress.phase} ${Math.round(coverageProgress.progress * 100)}%`
: `Cancel${elapsed > 0 ? ` (${elapsed}s)` : '...'}`}
</span>
</Button>
) : (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import NumberInput from '@/components/ui/NumberInput.tsx';
import FrequencySelector from '@/components/panels/FrequencySelector.tsx';
import FrequencyBandPanel from '@/components/panels/FrequencyBandPanel.tsx';
import ModalBackdrop from './ModalBackdrop.tsx';
export interface SiteFormValues {
@@ -104,6 +105,26 @@ const TEMPLATES = {
height: 3,
antennaType: 'omni' as const,
},
uhfTactical: {
label: 'UHF Tactical',
style: 'amber',
name: 'UHF Tactical Radio',
power: 25,
gain: 3,
frequency: 450,
height: 5,
antennaType: 'omni' as const,
},
vhfRepeater: {
label: 'VHF Repeater',
style: 'teal',
name: 'VHF Repeater',
power: 40,
gain: 6,
frequency: 150,
height: 25,
antennaType: 'omni' as const,
},
};
const TEMPLATE_COLORS: Record<string, string> = {
@@ -114,6 +135,8 @@ const TEMPLATE_COLORS: Record<string, string> = {
emerald: 'bg-emerald-100 hover:bg-emerald-200 text-emerald-700 dark:bg-emerald-900/30 dark:hover:bg-emerald-900/50 dark:text-emerald-300',
cyan: 'bg-cyan-100 hover:bg-cyan-200 text-cyan-700 dark:bg-cyan-900/30 dark:hover:bg-cyan-900/50 dark:text-cyan-300',
rose: 'bg-rose-100 hover:bg-rose-200 text-rose-700 dark:bg-rose-900/30 dark:hover:bg-rose-900/50 dark:text-rose-300',
amber: 'bg-amber-100 hover:bg-amber-200 text-amber-700 dark:bg-amber-900/30 dark:hover:bg-amber-900/50 dark:text-amber-300',
teal: 'bg-teal-100 hover:bg-teal-200 text-teal-700 dark:bg-teal-900/30 dark:hover:bg-teal-900/50 dark:text-teal-300',
};
function getDefaults(initialData?: Partial<SiteFormValues>): SiteFormValues {
@@ -350,6 +373,12 @@ export default function SiteConfigModal({
onChange={(v) => updateField('frequency', v)}
/>
{/* Band panel — UHF/VHF/LTE/5G grouped selector */}
<FrequencyBandPanel
value={form.frequency}
onChange={(v) => updateField('frequency', v)}
/>
{/* Physical Parameters separator */}
<div className="border-t border-gray-200 dark:border-white/10 pt-3">
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">

View File

@@ -0,0 +1,144 @@
/**
* UHF/VHF frequency band panel for tactical radio planning.
*
* Shows band-grouped frequency selection with tactical presets
* and propagation model info for each band.
*/
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
import type { FrequencyBand } from '@/types/index.ts';
interface FrequencyBandPanelProps {
value: number;
onChange: (freq: number) => void;
}
const BAND_COLORS: Record<string, { bg: string; text: string; border: string; active: string }> = {
VHF: {
bg: 'bg-green-50 dark:bg-green-900/20',
text: 'text-green-700 dark:text-green-300',
border: 'border-green-200 dark:border-green-800',
active: 'bg-green-600 dark:bg-green-500',
},
UHF: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
active: 'bg-amber-600 dark:bg-amber-500',
},
LTE: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-700 dark:text-blue-300',
border: 'border-blue-200 dark:border-blue-800',
active: 'bg-blue-600 dark:bg-blue-500',
},
'5G': {
bg: 'bg-purple-50 dark:bg-purple-900/20',
text: 'text-purple-700 dark:text-purple-300',
border: 'border-purple-200 dark:border-purple-800',
active: 'bg-purple-600 dark:bg-purple-500',
},
};
const PROPAGATION_MODELS: Record<string, string> = {
VHF: 'ITU-R P.1546 / Longley-Rice',
UHF: 'Okumura-Hata / Longley-Rice',
LTE: 'COST-231 Hata / Okumura-Hata',
'5G': 'Free-Space / COST-231',
};
function getBandForFrequency(freq: number): string | null {
const band = COMMON_FREQUENCIES.find((b) => Math.abs(b.value - freq) < 50);
return band?.type ?? null;
}
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
const currentBand = getBandForFrequency(value);
return (
<div className="space-y-3">
<div className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase tracking-wide">
Frequency Bands
</div>
{(Object.keys(FREQUENCY_GROUPS) as Array<keyof typeof FREQUENCY_GROUPS>).map((bandType) => {
const freqs = FREQUENCY_GROUPS[bandType];
const colors = BAND_COLORS[bandType] ?? BAND_COLORS.LTE;
const bandFrequencies = COMMON_FREQUENCIES.filter(
(b) => b.type === bandType
);
const isActiveBand = currentBand === bandType;
return (
<div
key={bandType}
className={`rounded-lg border p-2.5 ${
isActiveBand
? `${colors.border} ${colors.bg}`
: 'border-gray-200 dark:border-dark-border'
}`}
>
{/* Band header */}
<div className="flex items-center justify-between mb-1.5">
<span className={`text-xs font-bold ${isActiveBand ? colors.text : 'text-gray-600 dark:text-dark-muted'}`}>
{bandType}
</span>
<span className="text-[10px] text-gray-400 dark:text-dark-muted">
{PROPAGATION_MODELS[bandType]}
</span>
</div>
{/* Frequency buttons */}
<div className="flex flex-wrap gap-1">
{(freqs as readonly number[]).map((freq) => {
const isActive = value === freq;
const bandInfo = bandFrequencies.find(
(b: FrequencyBand) => b.value === freq
);
return (
<button
key={freq}
type="button"
onClick={() => onChange(freq)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors min-h-[28px]
${
isActive
? `${colors.active} text-white`
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-text dark:hover:bg-dark-muted'
}`}
title={bandInfo ? `${bandInfo.name}: ${bandInfo.range}` : `${freq} MHz`}
>
{freq}
</button>
);
})}
<span className="self-center text-[10px] text-gray-400 dark:text-dark-muted ml-0.5">MHz</span>
</div>
{/* Active band details */}
{isActiveBand && bandFrequencies.length > 0 && (
<div className={`mt-1.5 text-[11px] ${colors.text}`}>
{(() => {
const info = bandFrequencies.find((b: FrequencyBand) => Math.abs(b.value - value) < 50);
if (!info) return null;
return (
<>
<span className="font-medium">{info.name}</span>
{' · '}
{info.range}
{' · '}
λ={getWavelength(value)}
{' · '}
{info.characteristics.typical}
</>
);
})()}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -93,6 +93,17 @@ export const COMMON_FREQUENCIES: FrequencyBand[] = [
export const QUICK_FREQUENCIES = [800, 1800, 1900, 2100, 2600];
// Tactical radio presets for UHF/VHF
export const TACTICAL_FREQUENCIES = [150, 450];
// All quick frequencies grouped by band type
export const FREQUENCY_GROUPS = {
LTE: [800, 1800, 1900, 2100, 2600],
UHF: [450],
VHF: [150],
'5G': [3500],
} as const;
export function getFrequencyInfo(frequency: number): FrequencyBand | null {
return (
COMMON_FREQUENCIES.find((band) => Math.abs(band.value - frequency) < 50) ||

View File

@@ -0,0 +1,30 @@
/**
* React hook wrapping the WebSocket singleton service.
*
* Provides reactive connection state and progress for UI components.
* The actual WS connection is managed by wsService singleton so it
* persists across component remounts.
*
* Usage:
* const { connected, progress } = useWebSocket();
*/
import { useEffect, useState } from 'react';
import { wsService } from '@/services/websocket.ts';
import { useCoverageStore } from '@/store/coverage.ts';
export function useWebSocket() {
const [connected, setConnected] = useState(wsService.connected);
const progress = useCoverageStore((s) => s.progress);
useEffect(() => {
// Connect the singleton if not already connected
wsService.connect();
// Subscribe to connection state changes
const unsub = wsService.onConnectionChange(setConnected);
return unsub;
}, []);
return { connected, progress };
}

View File

@@ -0,0 +1,156 @@
/**
* Singleton WebSocket service for real-time coverage calculation.
*
* Manages a persistent WebSocket connection and provides calculate/cancel
* methods usable from Zustand stores (not just React components).
*
* Usage:
* import { wsService } from '@/services/websocket';
* wsService.connect();
* wsService.calculate(sites, settings, onResult, onError, onProgress);
*/
import { getApiBaseUrl } from '@/lib/desktop.ts';
import type { CoverageResponse } from '@/services/api.ts';
export interface WSProgress {
phase: string;
progress: number;
eta_seconds?: number;
}
type ProgressCallback = (progress: WSProgress) => void;
type ResultCallback = (data: CoverageResponse) => void;
type ErrorCallback = (error: string) => void;
type ConnectionCallback = (connected: boolean) => void;
interface PendingCalc {
onProgress?: ProgressCallback;
onResult: ResultCallback;
onError: ErrorCallback;
}
class WebSocketService {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
private _connected = false;
private _pendingCalcs = new Map<string, PendingCalc>();
private _connectionListeners = new Set<ConnectionCallback>();
get connected(): boolean {
return this._connected;
}
/** Register a listener for connection state changes. Returns unsubscribe fn. */
onConnectionChange(cb: ConnectionCallback): () => void {
this._connectionListeners.add(cb);
return () => this._connectionListeners.delete(cb);
}
private _setConnected(val: boolean): void {
this._connected = val;
for (const cb of this._connectionListeners) {
try { cb(val); } catch { /* ignore */ }
}
}
connect(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return;
const apiBase = getApiBaseUrl();
const url = apiBase.replace(/\/api\/?$/, '').replace(/^http/, 'ws') + '/ws';
try {
this.ws = new WebSocket(url);
} catch {
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
return;
}
this.ws.onopen = () => {
this._setConnected(true);
};
this.ws.onclose = () => {
this._setConnected(false);
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
};
this.ws.onerror = () => {
// onclose will handle reconnect
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
const calcId: string | undefined = msg.calculation_id;
const pending = calcId ? this._pendingCalcs.get(calcId) : undefined;
switch (msg.type) {
case 'progress':
pending?.onProgress?.({
phase: msg.phase,
progress: msg.progress,
eta_seconds: msg.eta_seconds,
});
break;
case 'result':
pending?.onResult(msg.data);
if (calcId) this._pendingCalcs.delete(calcId);
break;
case 'error':
pending?.onError(msg.message);
if (calcId) this._pendingCalcs.delete(calcId);
break;
}
} catch {
// Ignore parse errors
}
};
}
disconnect(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
this._setConnected(false);
}
/**
* Send a coverage calculation request via WebSocket.
* Returns the calculation ID, or undefined if WS is not connected.
*/
calculate(
sites: Array<Record<string, unknown>>,
settings: Record<string, unknown>,
onResult: ResultCallback,
onError: ErrorCallback,
onProgress?: ProgressCallback,
): string | undefined {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return undefined;
}
const calcId = crypto.randomUUID();
this._pendingCalcs.set(calcId, { onProgress, onResult, onError });
this.ws.send(JSON.stringify({
type: 'calculate',
id: calcId,
sites,
settings,
}));
return calcId;
}
/** Cancel a running calculation by ID. */
cancel(calcId: string): void {
this.ws?.send(JSON.stringify({ type: 'cancel', id: calcId }));
this._pendingCalcs.delete(calcId);
}
}
/** Singleton WebSocket service. */
export const wsService = new WebSocketService();

View File

@@ -1,8 +1,10 @@
import { create } from 'zustand';
import { api } from '@/services/api.ts';
import { wsService } from '@/services/websocket.ts';
import type { WSProgress } from '@/services/websocket.ts';
import { useSitesStore } from '@/store/sites.ts';
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
import type { ApiSiteParams } from '@/services/api.ts';
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
interface CoverageState {
result: CoverageResult | null;
@@ -11,6 +13,10 @@ interface CoverageState {
heatmapVisible: boolean;
error: string | null;
// WebSocket progress (null when not calculating or using HTTP)
progress: WSProgress | null;
activeCalcId: string | null;
setResult: (result: CoverageResult | null) => void;
clearCoverage: () => void;
setIsCalculating: (val: boolean) => void;
@@ -24,6 +30,68 @@ interface CoverageState {
cancelCalculation: () => void;
}
function buildApiSites(): ApiSiteParams[] {
return useSitesStore.getState().sites
.filter((s) => s.visible)
.map((site) => ({
lat: site.lat,
lon: site.lon,
height: site.height,
power: site.power,
gain: site.gain,
frequency: site.frequency,
azimuth: site.antennaType === 'sector' ? site.azimuth : undefined,
beamwidth: site.antennaType === 'sector' ? site.beamwidth : undefined,
}));
}
function buildApiSettings(settings: CoverageSettings) {
return {
radius: settings.radius * 1000, // km → meters
resolution: settings.resolution,
min_signal: settings.rsrpThreshold,
preset: settings.preset,
use_terrain: settings.use_terrain,
use_buildings: settings.use_buildings,
use_materials: settings.use_materials,
use_dominant_path: settings.use_dominant_path,
use_street_canyon: settings.use_street_canyon,
use_reflections: settings.use_reflections,
use_water_reflection: settings.use_water_reflection,
use_vegetation: settings.use_vegetation,
season: settings.season,
rain_rate: settings.rain_rate,
indoor_loss_type: settings.indoor_loss_type,
use_atmospheric: settings.use_atmospheric,
temperature_c: settings.temperature_c,
humidity_percent: settings.humidity_percent,
};
}
function responseToResult(response: CoverageResponse, settings: CoverageSettings): CoverageResult {
return {
points: response.points.map((p) => ({
lat: p.lat,
lon: p.lon,
rsrp: p.rsrp,
distance: p.distance,
has_los: p.has_los,
terrain_loss: p.terrain_loss,
building_loss: p.building_loss,
reflection_gain: p.reflection_gain,
vegetation_loss: p.vegetation_loss,
rain_loss: p.rain_loss,
indoor_loss: p.indoor_loss,
atmospheric_loss: p.atmospheric_loss,
})),
calculationTime: response.computation_time,
totalPoints: response.count,
settings: settings,
stats: response.stats as CoverageApiStats,
modelsUsed: response.models_used,
};
}
export const useCoverageStore = create<CoverageState>((set, get) => ({
result: null,
isCalculating: false,
@@ -55,6 +123,8 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
},
heatmapVisible: true,
error: null,
progress: null,
activeCalcId: null,
setResult: (result) => set({ result }),
clearCoverage: () => set({ result: null, error: null }),
@@ -76,81 +146,51 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
return;
}
set({ isCalculating: true, error: null });
const apiSites = buildApiSites();
if (apiSites.length === 0) {
set({ error: 'No visible sites to calculate' });
return;
}
try {
// Convert sites to API format
// Each site is treated as a separate sector (flat model)
const apiSites: ApiSiteParams[] = sites
.filter((s) => s.visible)
.map((site) => ({
lat: site.lat,
lon: site.lon,
height: site.height,
power: site.power, // Already in dBm
gain: site.gain,
frequency: site.frequency,
azimuth: site.antennaType === 'sector' ? site.azimuth : undefined,
beamwidth: site.antennaType === 'sector' ? site.beamwidth : undefined,
}));
const apiSettings = buildApiSettings(settings);
if (apiSites.length === 0) {
set({ isCalculating: false, error: 'No visible sites to calculate' });
return;
set({ isCalculating: true, error: null, progress: null, activeCalcId: null });
// Try WebSocket first (provides real-time progress)
if (wsService.connected) {
const calcId = wsService.calculate(
apiSites as unknown as Array<Record<string, unknown>>,
apiSettings as unknown as Record<string, unknown>,
// onResult
(data) => {
const result = responseToResult(data, settings);
set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
},
// onError
(error) => {
set({ isCalculating: false, error, progress: null, activeCalcId: null });
},
// onProgress
(progress) => {
set({ progress });
},
);
if (calcId) {
set({ activeCalcId: calcId });
return; // WS handles the rest asynchronously
}
}
// HTTP fallback (no progress updates, waits for full result)
try {
const response = await api.calculateCoverage({
sites: apiSites,
settings: {
radius: settings.radius * 1000, // km → meters
resolution: settings.resolution,
min_signal: settings.rsrpThreshold,
preset: settings.preset,
use_terrain: settings.use_terrain,
use_buildings: settings.use_buildings,
use_materials: settings.use_materials,
use_dominant_path: settings.use_dominant_path,
use_street_canyon: settings.use_street_canyon,
use_reflections: settings.use_reflections,
use_water_reflection: settings.use_water_reflection,
use_vegetation: settings.use_vegetation,
season: settings.season,
rain_rate: settings.rain_rate,
indoor_loss_type: settings.indoor_loss_type,
use_atmospheric: settings.use_atmospheric,
temperature_c: settings.temperature_c,
humidity_percent: settings.humidity_percent,
},
settings: apiSettings,
});
// Map API response to CoverageResult for existing heatmap/boundary components
const result: CoverageResult = {
points: response.points.map((p) => ({
lat: p.lat,
lon: p.lon,
rsrp: p.rsrp,
distance: p.distance,
has_los: p.has_los,
terrain_loss: p.terrain_loss,
building_loss: p.building_loss,
reflection_gain: p.reflection_gain,
vegetation_loss: p.vegetation_loss,
rain_loss: p.rain_loss,
indoor_loss: p.indoor_loss,
atmospheric_loss: p.atmospheric_loss,
})),
calculationTime: response.computation_time,
totalPoints: response.count,
settings: settings,
stats: response.stats as CoverageApiStats,
modelsUsed: response.models_used,
};
set({
result,
isCalculating: false,
error: null,
});
const result = responseToResult(response, settings);
set({ result, isCalculating: false, error: null });
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
set({ isCalculating: false });
@@ -164,7 +204,11 @@ export const useCoverageStore = create<CoverageState>((set, get) => ({
},
cancelCalculation: () => {
const { activeCalcId } = get();
if (activeCalcId) {
wsService.cancel(activeCalcId);
}
api.cancelCalculation();
set({ isCalculating: false });
set({ isCalculating: false, progress: null, activeCalcId: null });
},
}));