-
+
─ ─ Coverage boundary ({threshold} dBm)
diff --git a/frontend/src/components/modals/SiteConfigModal.tsx b/frontend/src/components/modals/SiteConfigModal.tsx
index 363d54a..a571a08 100644
--- a/frontend/src/components/modals/SiteConfigModal.tsx
+++ b/frontend/src/components/modals/SiteConfigModal.tsx
@@ -1,6 +1,5 @@
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';
@@ -31,6 +30,7 @@ interface SiteConfigModalProps {
const TEMPLATES = {
limesdr: {
label: 'LimeSDR',
+ tooltip: 'SDR dev board — low power, short range testing (20 dBm, 2 dBi, 1800 MHz)',
style: 'purple',
name: 'LimeSDR Mini',
power: 20,
@@ -41,6 +41,7 @@ const TEMPLATES = {
},
lowBBU: {
label: 'Low BBU',
+ tooltip: 'Low-power baseband unit — suburban/campus coverage (40 dBm, 8 dBi, 1800 MHz)',
style: 'green',
name: 'Low Power BBU',
power: 40,
@@ -51,6 +52,7 @@ const TEMPLATES = {
},
highBBU: {
label: 'High BBU',
+ tooltip: 'High-power BBU — urban macro sector (43 dBm, 15 dBi, 65\u00B0 sector)',
style: 'orange',
name: 'High Power BBU',
power: 43,
@@ -63,6 +65,7 @@ const TEMPLATES = {
},
urbanMacro: {
label: 'Urban Macro',
+ tooltip: 'Standard urban macro site — rooftop/tower sector (43 dBm, 18 dBi, 65\u00B0 sector)',
style: 'blue',
name: 'Urban Macro Site',
power: 43,
@@ -75,6 +78,7 @@ const TEMPLATES = {
},
ruralTower: {
label: 'Rural Tower',
+ tooltip: 'Rural high tower — long range 800 MHz omni coverage (46 dBm, 8 dBi, 50m)',
style: 'emerald',
name: 'Rural Tower',
power: 46,
@@ -85,6 +89,7 @@ const TEMPLATES = {
},
smallCell: {
label: 'Small Cell',
+ tooltip: 'Urban small cell — street-level high capacity (30 dBm, 12 dBi, 2600 MHz)',
style: 'cyan',
name: 'Small Cell',
power: 30,
@@ -97,6 +102,7 @@ const TEMPLATES = {
},
indoorDAS: {
label: 'Indoor DAS',
+ tooltip: 'Indoor distributed antenna — in-building coverage (23 dBm, 2 dBi, 2100 MHz)',
style: 'rose',
name: 'Indoor DAS',
power: 23,
@@ -107,6 +113,7 @@ const TEMPLATES = {
},
uhfTactical: {
label: 'UHF Tactical',
+ tooltip: 'UHF tactical radio — man-portable field comms (25 dBm, 3 dBi, 450 MHz)',
style: 'amber',
name: 'UHF Tactical Radio',
power: 25,
@@ -117,6 +124,7 @@ const TEMPLATES = {
},
vhfRepeater: {
label: 'VHF Repeater',
+ tooltip: 'VHF repeater — long range voice/data relay (40 dBm, 6 dBi, 150 MHz)',
style: 'teal',
name: 'VHF Repeater',
power: 40,
@@ -203,8 +211,8 @@ export default function SiteConfigModal({
if (form.power < 10 || form.power > 50) {
newErrors.power = 'Power must be 10-50 dBm';
}
- if (form.gain < 0 || form.gain > 25) {
- newErrors.gain = 'Gain must be 0-25 dBi';
+ if (form.gain < 0 || form.gain > 30) {
+ newErrors.gain = 'Gain must be 0-30 dBi';
}
if (form.frequency < 100 || form.frequency > 6000) {
newErrors.frequency = 'Frequency must be 100-6000 MHz';
@@ -360,20 +368,20 @@ export default function SiteConfigModal({
label="Antenna Gain"
value={form.gain}
min={0}
- max={25}
+ max={30}
step={0.5}
unit="dBi"
- hint="Omni 2-8, Sector 15-18, Parabolic 20-25"
+ hint={
+ form.gain <= 8
+ ? `Omni-directional (${form.gain} dBi)`
+ : form.gain <= 18
+ ? `Sector/Panel (${form.gain} dBi)`
+ : `Parabolic/Dish (${form.gain} dBi)`
+ }
onChange={(v) => updateField('gain', v)}
/>
- {/* Frequency */}
-
updateField('frequency', v)}
- />
-
- {/* Band panel — UHF/VHF/LTE/5G grouped selector */}
+ {/* Band panel — UHF/VHF/LTE/5G grouped selector + custom input */}
updateField('frequency', v)}
@@ -485,6 +493,7 @@ export default function SiteConfigModal({
key={key}
type="button"
onClick={() => applyTemplate(key as keyof typeof TEMPLATES)}
+ title={t.tooltip}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors min-h-[32px]
${TEMPLATE_COLORS[t.style] ?? TEMPLATE_COLORS.blue}`}
>
diff --git a/frontend/src/components/panels/CoverageStats.tsx b/frontend/src/components/panels/CoverageStats.tsx
index d3c83c1..845463a 100644
--- a/frontend/src/components/panels/CoverageStats.tsx
+++ b/frontend/src/components/panels/CoverageStats.tsx
@@ -19,8 +19,8 @@ function estimateAreaKm2(pointCount: number, resolutionM: number): number {
}
const LEVELS = [
- { label: 'Excellent', threshold: -70, color: 'bg-green-500' },
- { label: 'Good', threshold: -85, color: 'bg-lime-500' },
+ { label: 'Excellent', threshold: -70, color: 'bg-blue-500' },
+ { label: 'Good', threshold: -85, color: 'bg-green-500' },
{ label: 'Fair', threshold: -100, color: 'bg-yellow-500' },
{ label: 'Weak', threshold: -Infinity, color: 'bg-red-500' },
] as const;
diff --git a/frontend/src/components/panels/FrequencyBandPanel.tsx b/frontend/src/components/panels/FrequencyBandPanel.tsx
index 2b23875..aa8af40 100644
--- a/frontend/src/components/panels/FrequencyBandPanel.tsx
+++ b/frontend/src/components/panels/FrequencyBandPanel.tsx
@@ -5,6 +5,7 @@
* and propagation model info for each band.
*/
+import { useState } from 'react';
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS, getWavelength } from '@/constants/frequencies.ts';
import type { FrequencyBand } from '@/types/index.ts';
@@ -54,11 +55,25 @@ function getBandForFrequency(freq: number): string | null {
export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPanelProps) {
const currentBand = getBandForFrequency(value);
+ const [customInput, setCustomInput] = useState('');
+
+ const handleCustomSubmit = () => {
+ const parsed = parseInt(customInput, 10);
+ if (parsed > 0 && parsed <= 100000) {
+ onChange(parsed);
+ setCustomInput('');
+ }
+ };
return (
-
- Frequency Bands
+
+
+ Operating Frequency
+
+
+ {value} MHz
+
{(Object.keys(FREQUENCY_GROUPS) as Array
).map((bandType) => {
@@ -139,6 +154,28 @@ export default function FrequencyBandPanel({ value, onChange }: FrequencyBandPan
);
})}
+
+ {/* Custom frequency input */}
+
+ setCustomInput(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleCustomSubmit()}
+ className="flex-1 px-2.5 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-xs
+ focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+ min={1}
+ max={100000}
+ />
+
+
);
}
diff --git a/frontend/src/components/panels/HistoryPanel.tsx b/frontend/src/components/panels/HistoryPanel.tsx
new file mode 100644
index 0000000..95f8afb
--- /dev/null
+++ b/frontend/src/components/panels/HistoryPanel.tsx
@@ -0,0 +1,137 @@
+import { useState } from 'react';
+import { useCalcHistoryStore } from '@/store/calcHistory.ts';
+import type { CalculationEntry } from '@/store/calcHistory.ts';
+
+function EntryDetail({ entry }: { entry: CalculationEntry }) {
+ return (
+
+ {/* Coverage breakdown with percentages */}
+
+
+
+ {entry.coverage.excellent.toFixed(0)}%
+
+
Excellent
+
+
+
+ {entry.coverage.good.toFixed(0)}%
+
+
Good
+
+
+
+ {entry.coverage.fair.toFixed(0)}%
+
+
Fair
+
+
+
+ {entry.coverage.weak.toFixed(0)}%
+
+
Weak
+
+
+
+ {/* RSRP details */}
+
+ Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm
+ Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm
+
+
+ );
+}
+
+export default function HistoryPanel() {
+ const entries = useCalcHistoryStore((s) => s.entries);
+ const clearHistory = useCalcHistoryStore((s) => s.clearHistory);
+ const [expanded, setExpanded] = useState(false);
+ const [expandedEntry, setExpandedEntry] = useState(null);
+
+ if (entries.length === 0) return null;
+
+ return (
+
+
+
+ {expanded && (
+
+ )}
+
+
+ {expanded && (
+
+ {entries.map((entry) => {
+ const isOpen = expandedEntry === entry.id;
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/panels/ResultsPanel.tsx b/frontend/src/components/panels/ResultsPanel.tsx
new file mode 100644
index 0000000..239eca2
--- /dev/null
+++ b/frontend/src/components/panels/ResultsPanel.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { useCoverageStore } from '@/store/coverage.ts';
+import type { CoverageResult } from '@/types/index.ts';
+
+function classifyCoverage(points: Array<{ rsrp: number }>) {
+ const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
+ for (const p of points) {
+ if (p.rsrp > -70) counts.excellent++;
+ else if (p.rsrp > -85) counts.good++;
+ else if (p.rsrp > -100) counts.fair++;
+ else counts.weak++;
+ }
+ return counts;
+}
+
+const AUTO_DISMISS_MS = 10_000;
+
+export default function ResultsPanel() {
+ const result = useCoverageStore((s) => s.result);
+ const [visible, setVisible] = useState(false);
+ const [show, setShow] = useState(false);
+ const timerRef = useRef | undefined>(undefined);
+ const prevResultRef = useRef(null);
+
+ const dismiss = useCallback(() => {
+ setVisible(false);
+ setTimeout(() => setShow(false), 300);
+ }, []);
+
+ useEffect(() => {
+ // Only trigger on NEW result (not initial mount with existing result)
+ if (result && result !== prevResultRef.current && result.points.length > 0) {
+ setShow(true);
+ requestAnimationFrame(() => setVisible(true));
+
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(dismiss, AUTO_DISMISS_MS);
+ }
+ prevResultRef.current = result;
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, [result, dismiss]);
+
+ if (!show || !result) return null;
+
+ const counts = classifyCoverage(result.points);
+ const total = result.points.length;
+ const preset = result.settings.preset ?? 'standard';
+ const timeStr = result.calculationTime.toFixed(1);
+
+ return (
+ <>
+
+
+ {/* Header */}
+
+
+ Calculation Complete
+
+
+
+
+ {/* Body */}
+
+ {/* Time + points */}
+
+
+ {timeStr}s
+
+
+ {total.toLocaleString()} points
+
+
+
+ {/* Coverage breakdown bar */}
+
+ {counts.excellent > 0 && (
+
+ )}
+ {counts.good > 0 && (
+
+ )}
+ {counts.fair > 0 && (
+
+ )}
+ {counts.weak > 0 && (
+
+ )}
+
+
+ {/* Coverage percentages */}
+
+
+
+ {total > 0 ? ((counts.excellent / total) * 100).toFixed(0) : 0}%
+
+
Exc
+
+
+
+ {total > 0 ? ((counts.good / total) * 100).toFixed(0) : 0}%
+
+
Good
+
+
+
+ {total > 0 ? ((counts.fair / total) * 100).toFixed(0) : 0}%
+
+
Fair
+
+
+
+ {total > 0 ? ((counts.weak / total) * 100).toFixed(0) : 0}%
+
+
Weak
+
+
+
+ {/* Metadata */}
+
+
+ {preset}
+
+
+ {result.settings.radius}km
+
+
+ {result.settings.resolution}m
+
+ {result.modelsUsed && result.modelsUsed.length > 0 && (
+
+ {result.modelsUsed.length} models
+
+ )}
+
+
+
+ {/* Auto-dismiss progress bar */}
+
+
+ >
+ );
+}
diff --git a/frontend/src/services/websocket.ts b/frontend/src/services/websocket.ts
index 0ec95a6..b085d75 100644
--- a/frontend/src/services/websocket.ts
+++ b/frontend/src/services/websocket.ts
@@ -33,6 +33,7 @@ interface PendingCalc {
class WebSocketService {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType | undefined;
+ private pingTimer: ReturnType | undefined;
private _connected = false;
private _pendingCalcs = new Map();
private _connectionListeners = new Set();
@@ -70,10 +71,20 @@ class WebSocketService {
this.ws.onopen = () => {
this._setConnected(true);
+ // Keepalive pings every 30s to prevent connection timeout during long calculations
+ if (this.pingTimer) clearInterval(this.pingTimer);
+ this.pingTimer = setInterval(() => {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify({ type: 'ping' }));
+ }
+ }, 30_000);
};
this.ws.onclose = () => {
this._setConnected(false);
+ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
+ // Fail all pending calculations — their callbacks reference the old socket
+ this._failPendingCalcs('WebSocket disconnected');
this.reconnectTimer = setTimeout(() => this.connect(), 2000);
};
@@ -121,8 +132,18 @@ class WebSocketService {
};
}
+ /** Fail all pending calculations (e.g. on disconnect). */
+ private _failPendingCalcs(reason: string): void {
+ for (const [calcId, pending] of this._pendingCalcs) {
+ try { pending.onError(reason); } catch { /* ignore */ }
+ this._pendingCalcs.delete(calcId);
+ }
+ }
+
disconnect(): void {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = undefined; }
+ this._failPendingCalcs('WebSocket disconnected');
this.ws?.close();
this.ws = null;
this._setConnected(false);
diff --git a/frontend/src/store/calcHistory.ts b/frontend/src/store/calcHistory.ts
new file mode 100644
index 0000000..f5a271f
--- /dev/null
+++ b/frontend/src/store/calcHistory.ts
@@ -0,0 +1,38 @@
+import { create } from 'zustand';
+
+export interface CalculationEntry {
+ id: string;
+ timestamp: Date;
+ preset: string;
+ radius: number;
+ resolution: number;
+ computationTime: number;
+ totalPoints: number;
+ coverage: { excellent: number; good: number; fair: number; weak: number };
+ avgRsrp: number;
+ rangeMin: number;
+ rangeMax: number;
+}
+
+interface CalcHistoryState {
+ entries: CalculationEntry[];
+ addEntry: (entry: CalculationEntry) => void;
+ clearHistory: () => void;
+}
+
+const MAX_ENTRIES = 50;
+
+export const useCalcHistoryStore = create((set) => ({
+ entries: [],
+
+ addEntry: (entry) =>
+ set((state) => {
+ const entries = [entry, ...state.entries];
+ if (entries.length > MAX_ENTRIES) {
+ entries.length = MAX_ENTRIES;
+ }
+ return { entries };
+ }),
+
+ clearHistory: () => set({ entries: [] }),
+}));
diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts
index 7746283..d55c547 100644
--- a/frontend/src/store/coverage.ts
+++ b/frontend/src/store/coverage.ts
@@ -3,6 +3,9 @@ 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 { useToastStore } from '@/components/ui/Toast.tsx';
+import { useCalcHistoryStore } from '@/store/calcHistory.ts';
+import type { CalculationEntry } from '@/store/calcHistory.ts';
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
@@ -49,7 +52,7 @@ function buildApiSettings(settings: CoverageSettings) {
return {
radius: settings.radius * 1000, // km → meters
resolution: settings.resolution,
- min_signal: settings.rsrpThreshold,
+ min_signal: -130, // Send all useful points; frontend filters visually via rsrpThreshold
preset: settings.preset,
use_terrain: settings.use_terrain,
use_buildings: settings.use_buildings,
@@ -92,6 +95,44 @@ function responseToResult(response: CoverageResponse, settings: CoverageSettings
};
}
+function buildHistoryEntry(result: CoverageResult): CalculationEntry {
+ const counts = { excellent: 0, good: 0, fair: 0, weak: 0 };
+ let minRsrp = Infinity;
+ let maxRsrp = -Infinity;
+
+ for (const p of result.points) {
+ if (p.rsrp > -70) counts.excellent++;
+ else if (p.rsrp > -85) counts.good++;
+ else if (p.rsrp > -100) counts.fair++;
+ else counts.weak++;
+ if (p.rsrp < minRsrp) minRsrp = p.rsrp;
+ if (p.rsrp > maxRsrp) maxRsrp = p.rsrp;
+ }
+
+ const total = result.points.length;
+ const avgRsrp = result.stats?.avg_rsrp
+ ?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
+
+ return {
+ id: crypto.randomUUID(),
+ timestamp: new Date(),
+ preset: result.settings.preset ?? 'standard',
+ radius: result.settings.radius,
+ resolution: result.settings.resolution,
+ computationTime: result.calculationTime,
+ totalPoints: result.totalPoints,
+ coverage: {
+ excellent: total > 0 ? (counts.excellent / total) * 100 : 0,
+ good: total > 0 ? (counts.good / total) * 100 : 0,
+ fair: total > 0 ? (counts.fair / total) * 100 : 0,
+ weak: total > 0 ? (counts.weak / total) * 100 : 0,
+ },
+ avgRsrp,
+ rangeMin: minRsrp === Infinity ? 0 : minRsrp,
+ rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
+ };
+}
+
export const useCoverageStore = create((set, get) => ({
result: null,
isCalculating: false,
@@ -163,12 +204,36 @@ export const useCoverageStore = create((set, get) => ({
apiSettings as unknown as Record,
// onResult
(data) => {
- const result = responseToResult(data, settings);
- set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
+ try {
+ const result = responseToResult(data, settings);
+ set({ result, isCalculating: false, error: null, progress: null, activeCalcId: null });
+ // Show success toast for WS result
+ const addToast = useToastStore.getState().addToast;
+ if (result.points.length === 0) {
+ addToast('No coverage points. Try increasing radius.', 'warning');
+ } else {
+ const timeStr = result.calculationTime.toFixed(1);
+ const modelsStr = result.modelsUsed?.length
+ ? ` \u2022 ${result.modelsUsed.length} models`
+ : '';
+ addToast(
+ `Calculated ${result.totalPoints.toLocaleString()} points in ${timeStr}s${modelsStr}`,
+ 'success'
+ );
+ }
+ // Push to session history
+ if (result.points.length > 0) {
+ useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
+ }
+ } catch (err) {
+ console.error('[Coverage] Failed to process result:', err);
+ set({ isCalculating: false, error: 'Failed to process coverage result', progress: null, activeCalcId: null });
+ }
},
// onError
(error) => {
set({ isCalculating: false, error, progress: null, activeCalcId: null });
+ useToastStore.getState().addToast(`Calculation failed: ${error}`, 'error');
},
// onProgress
(progress) => {
@@ -191,6 +256,10 @@ export const useCoverageStore = create((set, get) => ({
const result = responseToResult(response, settings);
set({ result, isCalculating: false, error: null });
+ // Push to session history
+ if (result.points.length > 0) {
+ useCalcHistoryStore.getState().addEntry(buildHistoryEntry(result));
+ }
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
set({ isCalculating: false });
diff --git a/frontend/src/utils/colorGradient.ts b/frontend/src/utils/colorGradient.ts
index 7d6341a..ef27626 100644
--- a/frontend/src/utils/colorGradient.ts
+++ b/frontend/src/utils/colorGradient.ts
@@ -1,10 +1,11 @@
/**
* RSRP → color mapping with smooth gradient interpolation.
*
- * Purple → Orange palette:
- * -130 dBm = deep purple (no service)
- * -90 dBm = peach (fair)
- * -50 dBm = bright orange (excellent)
+ * CloudRF-style Red → Blue palette:
+ * -130 dBm = dark red (no service)
+ * -100 dBm = yellow (fair)
+ * -70 dBm = green (good)
+ * -50 dBm = deep blue (excellent)
*
* All functions are pure and allocation-free on the hot path
* (pre-built lookup table for fast per-pixel color resolution).
@@ -18,14 +19,13 @@ interface GradientStop {
}
const GRADIENT_STOPS: GradientStop[] = [
- { value: 0.0, r: 26, g: 0, b: 51 }, // #1a0033 — deep purple (no service)
- { value: 0.15, r: 74, g: 20, b: 140 }, // #4a148c — dark purple
- { value: 0.30, r: 123, g: 31, b: 162 }, // #7b1fa2 — purple (very weak)
- { value: 0.45, r: 171, g: 71, b: 188 }, // #ab47bc — light purple (weak)
- { value: 0.60, r: 255, g: 138, b: 101 }, // #ff8a65 — peach (fair)
- { value: 0.75, r: 255, g: 111, b: 0 }, // #ff6f00 — dark orange (good)
- { value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange (strong)
- { value: 1.0, r: 255, g: 183, b: 77 }, // #ffb74d — bright orange (excellent)
+ { value: 0.0, r: 127, g: 0, b: 0 }, // #7f0000 — dark red (no service)
+ { value: 0.15, r: 239, g: 68, b: 68 }, // #EF4444 — red (very weak)
+ { value: 0.30, r: 249, g: 115, b: 22 }, // #F97316 — orange (weak)
+ { value: 0.50, r: 234, g: 179, b: 8 }, // #EAB308 — yellow (fair)
+ { value: 0.70, r: 34, g: 197, b: 94 }, // #22C55E — green (good)
+ { value: 0.85, r: 59, g: 130, b: 246 }, // #3B82F6 — blue (strong)
+ { value: 1.0, r: 37, g: 99, b: 235 }, // #2563EB — deep blue (excellent)
];
/**
diff --git a/installer/detailed-result.json b/installer/detailed-result.json
index 9814f22..e8ff401 100644
--- a/installer/detailed-result.json
+++ b/installer/detailed-result.json
@@ -1 +1 @@
-{"detail":"Calculation timeout (5 min). Cleaned up 4 workers."}
\ No newline at end of file
+{"points":[{"lat":50.4427928,"lon":30.4535025,"rsrp":-114.57095757980863,"distance":4776.327858778684,"has_los":false,"terrain_loss":9.453017124933375,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4577471,"rsrp":-113.73544554762684,"distance":4480.317074443244,"has_los":false,"terrain_loss":9.5962410903238,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4619916,"rsrp":-113.75686460120053,"distance":4184.961133731838,"has_los":false,"terrain_loss":10.660926016719886,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4662361,"rsrp":-113.3591330102574,"distance":3890.4006156776886,"has_los":false,"terrain_loss":11.379720332657806,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4704806,"rsrp":-112.1779447063071,"distance":3596.8309487883807,"has_los":false,"terrain_loss":11.398794291595998,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4747251,"rsrp":-111.06508716890035,"distance":3304.5162231564223,"has_los":false,"terrain_loss":11.582639009992775,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4789697,"rsrp":-108.52928921396732,"distance":3013.814791497514,"has_los":false,"terrain_loss":10.45552982731549,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4832142,"rsrp":-111.10568226437505,"distance":2725.2588519891615,"has_los":false,"terrain_loss":14.571559091443957,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4874587,"rsrp":-110.89819632735274,"distance":2439.6005231322424,"has_los":false,"terrain_loss":16.058002693564312,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.4917032,"rsrp":-110.689718193417,"distance":2157.990803648135,"has_los":false,"terrain_loss":17.725921918894255,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.5468819,"rsrp":-102.78177239655489,"distance":2065.3031851927776,"has_los":false,"terrain_loss":10.489565288462506,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4427928,"lon":30.5511265,"rsrp":-110.26855203832413,"distance":2345.2105364801055,"has_los":false,"terrain_loss":16.032002570278294,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4535025,"rsrp":-112.92387778532812,"distance":4735.047144677672,"has_los":false,"terrain_loss":7.938728792975363,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4577471,"rsrp":-113.68081520629791,"distance":4436.300397751301,"has_los":false,"terrain_loss":9.692648034803243,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4619916,"rsrp":-112.79807445491802,"distance":4137.82140564606,"has_los":false,"terrain_loss":9.875431137024561,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4662361,"rsrp":-112.8337987225987,"distance":3839.6639292985146,"has_los":false,"terrain_loss":11.055206870054993,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4704806,"rsrp":-112.74692370104707,"distance":3541.909166204692,"has_los":false,"terrain_loss":12.203167158096548,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4747251,"rsrp":-111.77226907906913,"distance":3244.6679872148493,"has_los":false,"terrain_loss":12.56942259457833,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4789697,"rsrp":-110.87456545335785,"distance":2948.088765844669,"has_los":false,"terrain_loss":13.13811943341141,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4832142,"rsrp":-110.7816198622974,"distance":2652.409877823923,"has_los":false,"terrain_loss":14.661991877649589,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4874587,"rsrp":-110.36353142174855,"distance":2357.960415392841,"has_los":false,"terrain_loss":16.04403892497462,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.4917032,"rsrp":-110.87277531703376,"distance":2065.2662922392547,"has_los":false,"terrain_loss":18.58084148212976,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4454955,"lon":30.5511265,"rsrp":-118.1020000972668,"distance":2260.1692107739464,"has_los":false,"terrain_loss":24.430488393553528,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4535025,"rsrp":-113.11992733277572,"distance":4712.607289554922,"has_los":false,"terrain_loss":8.207449216573822,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4577471,"rsrp":-112.83275087840336,"distance":4412.359200038484,"has_los":false,"terrain_loss":8.927365146605734,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4619916,"rsrp":-112.09714938976785,"distance":4112.160580829812,"has_los":false,"terrain_loss":9.269672231226231,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4662361,"rsrp":-112.83007499387445,"distance":3812.014375144207,"has_los":false,"terrain_loss":11.162042841122865,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4704806,"rsrp":-111.8848696388209,"distance":3511.934022621575,"has_los":false,"terrain_loss":11.471130537497485,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4747251,"rsrp":-111.50050276421196,"distance":3211.93798173643,"has_los":false,"terrain_loss":12.452755296771086,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4789697,"rsrp":-111.30291051622041,"distance":2912.0452470704267,"has_los":false,"terrain_loss":13.754651072976774,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4832142,"rsrp":-110.67437957943807,"distance":2612.3079619184136,"has_los":false,"terrain_loss":14.787808487476044,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4874587,"rsrp":-109.64328535861614,"distance":2312.7767529815947,"has_los":false,"terrain_loss":15.619780163447215,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0},{"lat":50.4481982,"lon":30.4917032,"rsrp":-108.12941174673985,"distance":2013.5435901638145,"has_los":false,"terrain_loss":16.225480437967644,"building_loss":0.0,"reflection_gain":0.0,"vegetation_loss":0.0,"rain_loss":0.0,"indoor_loss":0.0,"atmospheric_loss":0.0}],"count":33,"settings":{"radius":5000.0,"resolution":300.0,"min_signal":-120.0,"environment":"urban","use_terrain":true,"use_buildings":true,"use_materials":true,"use_dominant_path":true,"use_street_canyon":false,"use_reflections":false,"use_water_reflection":false,"use_vegetation":true,"season":"summer","rain_rate":0.0,"indoor_loss_type":"none","use_atmospheric":false,"temperature_c":15.0,"humidity_percent":50.0,"preset":"detailed"},"stats":{"min_rsrp":-118.1020000972668,"max_rsrp":-102.78177239655489,"avg_rsrp":-111.65770170751283,"los_percentage":0.0,"points_with_buildings":0,"points_with_terrain_loss":33,"points_with_reflection_gain":0,"points_with_vegetation_loss":0,"points_with_rain_loss":0,"points_with_indoor_loss":0,"points_with_atmospheric_loss":0},"computation_time":67.47,"models_used":["COST-231-Hata","terrain_los","buildings","materials","dominant_path","vegetation"]}
\ No newline at end of file