@mytec: iter3.5.1 ready for testing
This commit is contained in:
@@ -20,6 +20,7 @@ import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
|
||||
import CoverageStats from '@/components/panels/CoverageStats.tsx';
|
||||
import HistoryPanel from '@/components/panels/HistoryPanel.tsx';
|
||||
import BatchFrequencyChange from '@/components/panels/BatchFrequencyChange.tsx';
|
||||
import ResultsPanel from '@/components/panels/ResultsPanel.tsx';
|
||||
import SiteImportExport from '@/components/panels/SiteImportExport.tsx';
|
||||
import { SiteConfigModal } from '@/components/modals/index.ts';
|
||||
@@ -728,6 +729,11 @@ export default function App() {
|
||||
{/* Site list */}
|
||||
<SiteList onEditSite={handleEditSite} onAddSite={handleAddManual} />
|
||||
|
||||
{/* Quick frequency change */}
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
|
||||
<BatchFrequencyChange />
|
||||
</div>
|
||||
|
||||
{/* Coverage settings */}
|
||||
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
|
||||
|
||||
@@ -107,7 +107,10 @@ export default function CoverageBoundary({
|
||||
/**
|
||||
* Compute concave hull boundary path(s) for a set of coverage points.
|
||||
*
|
||||
* maxEdge = resolution * 3 (in km) gives good detail without over-fitting.
|
||||
* Uses adaptive maxEdge based on point count and resolution:
|
||||
* - More points → smaller maxEdge for finer detail
|
||||
* - Larger resolution → larger maxEdge to avoid over-fitting
|
||||
*
|
||||
* Returns multiple paths if hull is a MultiPolygon (disjoint coverage areas).
|
||||
* Falls back to empty if hull computation fails (e.g., collinear points).
|
||||
*/
|
||||
@@ -121,8 +124,17 @@ function computeConcaveHulls(
|
||||
const features = pts.map((p) => point([p.lon, p.lat]));
|
||||
const fc = featureCollection(features);
|
||||
|
||||
// maxEdge in km — resolution * 3 balances detail vs smoothness
|
||||
const maxEdge = (resolutionM * 3) / 1000;
|
||||
// Adaptive maxEdge based on point density:
|
||||
// - Base: resolution * 2 (tighter fit)
|
||||
// - For sparse grids (<100 pts): use larger edge to avoid holes
|
||||
// - For dense grids (>1000 pts): use smaller edge for detail
|
||||
let multiplier = 2.0;
|
||||
if (pts.length < 100) {
|
||||
multiplier = 4.0; // Sparse: wider tolerance
|
||||
} else if (pts.length > 1000) {
|
||||
multiplier = 1.5; // Dense: finer detail
|
||||
}
|
||||
const maxEdge = (resolutionM * multiplier) / 1000;
|
||||
|
||||
try {
|
||||
const hull = concave(fc, { maxEdge, units: 'kilometers' });
|
||||
|
||||
77
frontend/src/components/panels/BatchFrequencyChange.tsx
Normal file
77
frontend/src/components/panels/BatchFrequencyChange.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Quick frequency band selector for setting all sectors at once.
|
||||
* Enables rapid comparison of coverage at different frequency bands.
|
||||
*/
|
||||
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
import { COMMON_FREQUENCIES, FREQUENCY_GROUPS } from '@/constants/frequencies.ts';
|
||||
|
||||
const QUICK_BANDS = [
|
||||
{ freq: 70, label: '70', color: 'text-indigo-400' },
|
||||
{ freq: 225, label: '225', color: 'text-cyan-400' },
|
||||
{ freq: 700, label: '700', color: 'text-red-400' },
|
||||
{ freq: 800, label: '800', color: 'text-orange-400' },
|
||||
{ freq: 900, label: '900', color: 'text-yellow-400' },
|
||||
{ freq: 1800, label: '1.8G', color: 'text-green-400' },
|
||||
{ freq: 2100, label: '2.1G', color: 'text-blue-400' },
|
||||
{ freq: 2600, label: '2.6G', color: 'text-purple-400' },
|
||||
{ freq: 3500, label: '3.5G', color: 'text-pink-400' },
|
||||
];
|
||||
|
||||
export default function BatchFrequencyChange() {
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
const setAllSitesFrequency = useSitesStore((s) => s.setAllSitesFrequency);
|
||||
|
||||
if (sites.length === 0) return null;
|
||||
|
||||
// Get current frequency (from first site)
|
||||
const currentFreq = sites[0]?.frequency ?? 1800;
|
||||
|
||||
// Check if all sites have same frequency
|
||||
const allSameFreq = sites.every((s) => s.frequency === currentFreq);
|
||||
|
||||
// Get band info
|
||||
const getBandName = (freq: number) => {
|
||||
const band = COMMON_FREQUENCIES.find((b) => b.value === freq);
|
||||
return band?.name ?? `${freq} MHz`;
|
||||
};
|
||||
|
||||
const handleSetFrequency = async (freq: number) => {
|
||||
await setAllSitesFrequency(freq);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 border-t border-gray-200 dark:border-dark-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-dark-muted uppercase">
|
||||
Quick Frequency
|
||||
</h4>
|
||||
<span className="text-[10px] text-gray-400 dark:text-dark-muted">
|
||||
{allSameFreq ? getBandName(currentFreq) : 'Mixed'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{QUICK_BANDS.map((b) => {
|
||||
const isActive = allSameFreq && currentFreq === b.freq;
|
||||
return (
|
||||
<button
|
||||
key={b.freq}
|
||||
onClick={() => handleSetFrequency(b.freq)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 ring-1 ring-blue-400'
|
||||
: 'bg-gray-100 hover:bg-gray-200 dark:bg-dark-border dark:hover:bg-dark-muted text-gray-700 dark:text-dark-text'
|
||||
}`}
|
||||
title={`Set all sectors to ${b.freq} MHz (${getBandName(b.freq)})`}
|
||||
>
|
||||
<span className={isActive ? '' : b.color}>{b.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1.5 text-[10px] text-gray-400 dark:text-dark-muted">
|
||||
Sets all {sites.length} sector{sites.length !== 1 ? 's' : ''} to selected band
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useCalcHistoryStore } from '@/store/calcHistory.ts';
|
||||
import type { CalculationEntry } from '@/store/calcHistory.ts';
|
||||
|
||||
function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
||||
const p = entry.propagation;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1.5 text-[10px]">
|
||||
{/* Coverage breakdown with percentages */}
|
||||
@@ -38,6 +40,73 @@ function EntryDetail({ entry }: { entry: CalculationEntry }) {
|
||||
<span>Avg RSRP: {entry.avgRsrp.toFixed(1)} dBm</span>
|
||||
<span>Range: {entry.rangeMin.toFixed(0)} / {entry.rangeMax.toFixed(0)} dBm</span>
|
||||
</div>
|
||||
|
||||
{/* Propagation details */}
|
||||
{p && (
|
||||
<div className="pt-1.5 border-t border-gray-100 dark:border-dark-border space-y-1">
|
||||
{/* Site parameters */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-gray-500 dark:text-dark-muted">
|
||||
<span>{p.frequency} MHz</span>
|
||||
<span>{p.txPower} dBm</span>
|
||||
<span>{p.antennaGain} dBi</span>
|
||||
<span>{p.antennaHeight} m</span>
|
||||
</div>
|
||||
|
||||
{/* Models used */}
|
||||
{p.modelsUsed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.modelsUsed.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="px-1 py-0.5 bg-gray-100 dark:bg-dark-border text-gray-600 dark:text-dark-muted rounded"
|
||||
>
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active toggles summary */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.use_terrain && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Terrain</span>
|
||||
)}
|
||||
{p.use_buildings && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Buildings</span>
|
||||
)}
|
||||
{p.use_materials && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Materials</span>
|
||||
)}
|
||||
{p.use_dominant_path && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">DomPath</span>
|
||||
)}
|
||||
{p.use_reflections && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Reflections</span>
|
||||
)}
|
||||
{p.use_vegetation && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Vegetation</span>
|
||||
)}
|
||||
{p.use_atmospheric && (
|
||||
<span className="px-1 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded">Atmospheric</span>
|
||||
)}
|
||||
{p.fading_margin > 0 && (
|
||||
<span className="px-1 py-0.5 bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 rounded">
|
||||
-{p.fading_margin} dB fade
|
||||
</span>
|
||||
)}
|
||||
{p.rain_rate > 0 && (
|
||||
<span className="px-1 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded">
|
||||
Rain {p.rain_rate} mm/h
|
||||
</span>
|
||||
)}
|
||||
{p.indoor_loss_type !== 'none' && (
|
||||
<span className="px-1 py-0.5 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded">
|
||||
Indoor: {p.indoor_loss_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Small header badge showing the active compute backend (CPU or GPU).
|
||||
* Fetches status on mount. Clicking opens a dropdown to switch devices.
|
||||
* Dropdown opens to the LEFT to avoid overlapping map controls.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
@@ -11,6 +12,8 @@ export default function GPUIndicator() {
|
||||
const [status, setStatus] = useState<GPUStatus | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, unknown> | null>(null);
|
||||
const [showDiag, setShowDiag] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,6 +26,7 @@ export default function GPUIndicator() {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
setShowDiag(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
@@ -32,7 +36,7 @@ export default function GPUIndicator() {
|
||||
if (!status) return null;
|
||||
|
||||
const isGPU = status.active_backend !== 'cpu';
|
||||
// Short label for header badge
|
||||
// Short label: just "CPU" or first word of GPU name
|
||||
const label = isGPU
|
||||
? (status.active_device?.name?.split(' ')[0] ?? 'GPU')
|
||||
: 'CPU';
|
||||
@@ -50,6 +54,16 @@ export default function GPUIndicator() {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleRunDiagnostics = async () => {
|
||||
try {
|
||||
const diag = await api.getGPUDiagnostics();
|
||||
setDiagnostics(diag);
|
||||
setShowDiag(true);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
@@ -59,13 +73,13 @@ export default function GPUIndicator() {
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-300 dark:hover:bg-green-900/50'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-border dark:text-dark-muted dark:hover:bg-dark-muted'
|
||||
}`}
|
||||
title={`Compute: ${label}`}
|
||||
title={`Compute: ${status.active_device?.name ?? label}`}
|
||||
>
|
||||
{isGPU ? '\u26A1' : '\u2699\uFE0F'} {label}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 w-56 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-50 py-1">
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-lg z-[9999] py-1">
|
||||
<div className="px-3 py-1.5 text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase">
|
||||
Compute Devices
|
||||
</div>
|
||||
@@ -98,6 +112,37 @@ export default function GPUIndicator() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show help when only CPU available */}
|
||||
{status.available_devices.length === 1 && status.active_backend === 'cpu' && (
|
||||
<div className="border-t border-gray-100 dark:border-dark-border mt-1 pt-2 px-3 pb-2">
|
||||
<div className="text-[10px] text-yellow-600 dark:text-yellow-400 mb-2">
|
||||
No GPU detected. For faster calculations:
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 dark:text-dark-muted space-y-0.5">
|
||||
<div>NVIDIA: <code className="bg-gray-100 dark:bg-dark-border px-1 rounded">pip install cupy-cuda12x</code></div>
|
||||
<div>Intel/AMD: <code className="bg-gray-100 dark:bg-dark-border px-1 rounded">pip install pyopencl</code></div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRunDiagnostics}
|
||||
className="mt-2 w-full text-[10px] text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
Run Diagnostics
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagnostics output */}
|
||||
{showDiag && diagnostics && (
|
||||
<div className="border-t border-gray-100 dark:border-dark-border mt-1 pt-2 px-3 pb-2 max-h-48 overflow-y-auto">
|
||||
<div className="text-[10px] font-semibold text-gray-500 dark:text-dark-muted mb-1">
|
||||
Diagnostics
|
||||
</div>
|
||||
<pre className="text-[9px] text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(diagnostics, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -240,6 +240,12 @@ class ApiService {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getGPUDiagnostics(): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${API_BASE}/api/gpu/diagnostics`);
|
||||
if (!response.ok) throw new Error('Failed to get GPU diagnostics');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// === Terrain Profile API ===
|
||||
|
||||
async getTerrainProfile(
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface PropagationSnapshot {
|
||||
// Models used
|
||||
modelsUsed: string[];
|
||||
use_terrain: boolean;
|
||||
use_buildings: boolean;
|
||||
use_materials: boolean;
|
||||
use_dominant_path: boolean;
|
||||
use_street_canyon: boolean;
|
||||
use_reflections: boolean;
|
||||
use_water_reflection: boolean;
|
||||
use_vegetation: boolean;
|
||||
use_atmospheric: boolean;
|
||||
// Site params (first site or average)
|
||||
frequency: number;
|
||||
txPower: number;
|
||||
antennaGain: number;
|
||||
antennaHeight: number;
|
||||
// Environmental
|
||||
season: string;
|
||||
rain_rate: number;
|
||||
indoor_loss_type: string;
|
||||
fading_margin: number;
|
||||
}
|
||||
|
||||
export interface CalculationEntry {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
@@ -12,6 +36,8 @@ export interface CalculationEntry {
|
||||
avgRsrp: number;
|
||||
rangeMin: number;
|
||||
rangeMax: number;
|
||||
// Propagation snapshot for detailed history
|
||||
propagation?: PropagationSnapshot;
|
||||
}
|
||||
|
||||
interface CalcHistoryState {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 { CalculationEntry, PropagationSnapshot } from '@/store/calcHistory.ts';
|
||||
import type { CoverageResult, CoverageSettings, CoverageApiStats } from '@/types/index.ts';
|
||||
import type { ApiSiteParams, CoverageResponse } from '@/services/api.ts';
|
||||
|
||||
@@ -119,6 +119,32 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
||||
const avgRsrp = result.stats?.avg_rsrp
|
||||
?? (total > 0 ? result.points.reduce((s, p) => s + p.rsrp, 0) / total : 0);
|
||||
|
||||
// Capture propagation snapshot from settings + sites
|
||||
const sites = useSitesStore.getState().sites.filter((s) => s.visible);
|
||||
const firstSite = sites[0];
|
||||
const settings = result.settings;
|
||||
|
||||
const propagation: PropagationSnapshot = {
|
||||
modelsUsed: result.modelsUsed ?? [],
|
||||
use_terrain: settings.use_terrain ?? true,
|
||||
use_buildings: settings.use_buildings ?? true,
|
||||
use_materials: settings.use_materials ?? true,
|
||||
use_dominant_path: settings.use_dominant_path ?? false,
|
||||
use_street_canyon: settings.use_street_canyon ?? false,
|
||||
use_reflections: settings.use_reflections ?? false,
|
||||
use_water_reflection: settings.use_water_reflection ?? false,
|
||||
use_vegetation: settings.use_vegetation ?? false,
|
||||
use_atmospheric: settings.use_atmospheric ?? false,
|
||||
frequency: firstSite?.frequency ?? 1800,
|
||||
txPower: firstSite?.power ?? 43,
|
||||
antennaGain: firstSite?.gain ?? 18,
|
||||
antennaHeight: firstSite?.height ?? 30,
|
||||
season: settings.season ?? 'summer',
|
||||
rain_rate: settings.rain_rate ?? 0,
|
||||
indoor_loss_type: settings.indoor_loss_type ?? 'none',
|
||||
fading_margin: settings.fading_margin ?? 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date(),
|
||||
@@ -136,6 +162,7 @@ function buildHistoryEntry(result: CoverageResult): CalculationEntry {
|
||||
avgRsrp,
|
||||
rangeMin: minRsrp === Infinity ? 0 : minRsrp,
|
||||
rangeMax: maxRsrp === -Infinity ? 0 : maxRsrp,
|
||||
propagation,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ interface SitesState {
|
||||
batchAdjustTilt: (delta: number) => Promise<void>;
|
||||
batchSetTilt: (tilt: number) => Promise<void>;
|
||||
batchSetFrequency: (frequency: number) => Promise<void>;
|
||||
setAllSitesFrequency: (frequency: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
@@ -584,4 +585,30 @@ export const useSitesStore = create<SitesState>((set, get) => ({
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
|
||||
setAllSitesFrequency: async (frequency: number) => {
|
||||
const { sites } = get();
|
||||
if (sites.length === 0) return;
|
||||
pushSnapshot('set all sites frequency', sites);
|
||||
const clamped = Math.max(100, Math.min(6000, frequency));
|
||||
const now = new Date();
|
||||
|
||||
const updatedSites = sites.map((site) => ({
|
||||
...site,
|
||||
frequency: clamped,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
for (const site of updatedSites) {
|
||||
await db.sites.put({
|
||||
id: site.id,
|
||||
data: JSON.stringify(site),
|
||||
createdAt: site.createdAt.getTime(),
|
||||
updatedAt: now.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
set({ sites: updatedSites });
|
||||
useCoverageStore.getState().clearCoverage();
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user