@mytec: iter3.5.1 ready for testing

This commit is contained in:
2026-02-03 12:04:36 +02:00
parent 255b91f257
commit 20d19d09ae
14 changed files with 1583 additions and 8 deletions

View File

@@ -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">

View File

@@ -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' });

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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();
},
}));