3D antenna radiation pattern visualization: analytical models + Three.js web UI
Add analytical radiation pattern models for 5 antenna types (dipole, monopole, EFHW, loop, patch) driven by S11 impedance measurements. Pure Python math with closed-form far-field equations — no numpy or simulation dependencies. New MCP tools: - radiation_pattern: scan S11 → find resonance → compute 3D pattern - radiation_pattern_from_data: compute from known impedance (no hardware) - radiation_pattern_multi: patterns across a frequency band for animation Web UI (opt-in via MCNANOVNA_WEB_PORT env var): - Three.js gain-mapped sphere with OrbitControls - Surface/wireframe/plane cut display modes with teal→amber color ramp - Smith chart overlay, dBi reference rings, E/H plane cross-sections - Real-time WebSocket push on new pattern computation - FastAPI backend shares process with MCP server, zero new core deps Frontend: Vite + TypeScript + Three.js, built assets committed to webui/static/. Optional dependencies: fastapi + uvicorn via pip install mcnanovna[webui].
This commit is contained in:
parent
e0fe09f3b8
commit
646c92324d
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
17
frontend/index.html
Normal file
17
frontend/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>mcnanovna -- Radiation Pattern</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="controls"></div>
|
||||||
|
<div id="scene-container"></div>
|
||||||
|
<canvas id="smith-chart" width="200" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1194
frontend/package-lock.json
generated
Normal file
1194
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.182.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"three": "^0.182.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
95
frontend/src/api.ts
Normal file
95
frontend/src/api.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import type { PatternData, ComputeParams, ScanParams } from './types';
|
||||||
|
|
||||||
|
const BASE_URL = '';
|
||||||
|
|
||||||
|
export async function fetchPattern(params: ComputeParams): Promise<PatternData> {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/pattern/compute`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Compute failed (${resp.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanPattern(params: ScanParams): Promise<PatternData> {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/pattern`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const body = await resp.text();
|
||||||
|
throw new Error(`Scan failed (${resp.status}): ${body}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatus(): Promise<{ connected: boolean; device?: string }> {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/status`);
|
||||||
|
if (!resp.ok) throw new Error(`Status check failed (${resp.status})`);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBands(): Promise<Record<string, { start_hz: number; stop_hz: number }>> {
|
||||||
|
const resp = await fetch(`${BASE_URL}/api/bands`);
|
||||||
|
if (!resp.ok) throw new Error(`Bands fetch failed (${resp.status})`);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PatternCallback = (data: PatternData) => void;
|
||||||
|
export type StatusCallback = (connected: boolean) => void;
|
||||||
|
|
||||||
|
export function connectWebSocket(
|
||||||
|
onPattern: PatternCallback,
|
||||||
|
onStatus: StatusCallback
|
||||||
|
): { close: () => void } {
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const url = `${protocol}//${window.location.host}/ws/pattern`;
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
onStatus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as PatternData;
|
||||||
|
onPattern(data);
|
||||||
|
} catch {
|
||||||
|
// non-pattern message, ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
onStatus(false);
|
||||||
|
if (!closed) {
|
||||||
|
reconnectTimer = setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
ws?.close();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
ws?.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
232
frontend/src/controls.ts
Normal file
232
frontend/src/controls.ts
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import type { DisplayMode, AppState } from './types';
|
||||||
|
import { iconRadio, iconActivity, iconCrosshair, iconWifi, iconWifiOff } from './icons';
|
||||||
|
|
||||||
|
export interface ControlCallbacks {
|
||||||
|
onCompute: () => void;
|
||||||
|
onScan: () => void;
|
||||||
|
onDisplayModeChange: (mode: DisplayMode) => void;
|
||||||
|
onAntennaTypeChange: (type: string) => void;
|
||||||
|
onFrequencyChange: (mhz: number) => void;
|
||||||
|
onImpedanceRealChange: (r: number) => void;
|
||||||
|
onImpedanceImagChange: (x: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANTENNA_TYPES = [
|
||||||
|
{ value: 'dipole', label: 'Dipole' },
|
||||||
|
{ value: 'monopole', label: 'Monopole' },
|
||||||
|
{ value: 'efhw', label: 'EFHW' },
|
||||||
|
{ value: 'loop', label: 'Loop' },
|
||||||
|
{ value: 'patch', label: 'Patch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DISPLAY_MODES: { value: DisplayMode; label: string }[] = [
|
||||||
|
{ value: 'surface', label: 'Surface' },
|
||||||
|
{ value: 'wireframe', label: 'Wireframe' },
|
||||||
|
{ value: 'e-plane', label: 'E-Plane' },
|
||||||
|
{ value: 'h-plane', label: 'H-Plane' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createControls(
|
||||||
|
container: HTMLElement,
|
||||||
|
callbacks: ControlCallbacks,
|
||||||
|
initialState: AppState
|
||||||
|
): {
|
||||||
|
updateStatus: (connected: boolean) => void;
|
||||||
|
updatePeakGain: (dbi: number | null) => void;
|
||||||
|
updateLoading: (loading: boolean) => void;
|
||||||
|
} {
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.className = 'controls-panel';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = el('div', 'controls-header');
|
||||||
|
header.innerHTML = `
|
||||||
|
<div class="controls-logo">
|
||||||
|
${iconRadio}
|
||||||
|
<span>mcnanovna</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls-subtitle">Radiation Pattern</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
const statusEl = el('div', 'controls-status');
|
||||||
|
statusEl.innerHTML = statusHtml(initialState.connected);
|
||||||
|
container.appendChild(statusEl);
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
container.appendChild(el('hr', 'controls-sep'));
|
||||||
|
|
||||||
|
// Section: Antenna
|
||||||
|
container.appendChild(sectionTitle('Antenna Configuration', iconSettings()));
|
||||||
|
|
||||||
|
// Antenna type
|
||||||
|
const antennaGroup = fieldGroup('Type');
|
||||||
|
const antennaSelect = document.createElement('select');
|
||||||
|
antennaSelect.className = 'ctrl-select';
|
||||||
|
ANTENNA_TYPES.forEach((t) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.value;
|
||||||
|
opt.textContent = t.label;
|
||||||
|
if (t.value === initialState.antennaType) opt.selected = true;
|
||||||
|
antennaSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
antennaSelect.addEventListener('change', () => callbacks.onAntennaTypeChange(antennaSelect.value));
|
||||||
|
antennaGroup.appendChild(antennaSelect);
|
||||||
|
container.appendChild(antennaGroup);
|
||||||
|
|
||||||
|
// Frequency
|
||||||
|
const freqGroup = fieldGroup('Frequency (MHz)');
|
||||||
|
const freqInput = document.createElement('input');
|
||||||
|
freqInput.type = 'number';
|
||||||
|
freqInput.className = 'ctrl-input';
|
||||||
|
freqInput.value = String(initialState.frequencyMhz);
|
||||||
|
freqInput.step = '0.1';
|
||||||
|
freqInput.min = '0.1';
|
||||||
|
freqInput.max = '3000';
|
||||||
|
freqInput.addEventListener('change', () => {
|
||||||
|
const v = parseFloat(freqInput.value);
|
||||||
|
if (!isNaN(v) && v > 0) callbacks.onFrequencyChange(v);
|
||||||
|
});
|
||||||
|
freqGroup.appendChild(freqInput);
|
||||||
|
container.appendChild(freqGroup);
|
||||||
|
|
||||||
|
// Impedance row
|
||||||
|
const impedRow = el('div', 'controls-row');
|
||||||
|
|
||||||
|
const rGroup = fieldGroup('R (\u03A9)');
|
||||||
|
const rInput = document.createElement('input');
|
||||||
|
rInput.type = 'number';
|
||||||
|
rInput.className = 'ctrl-input';
|
||||||
|
rInput.value = String(initialState.impedanceReal);
|
||||||
|
rInput.step = '1';
|
||||||
|
rInput.addEventListener('change', () => {
|
||||||
|
const v = parseFloat(rInput.value);
|
||||||
|
if (!isNaN(v)) callbacks.onImpedanceRealChange(v);
|
||||||
|
});
|
||||||
|
rGroup.appendChild(rInput);
|
||||||
|
impedRow.appendChild(rGroup);
|
||||||
|
|
||||||
|
const xGroup = fieldGroup('X (\u03A9)');
|
||||||
|
const xInput = document.createElement('input');
|
||||||
|
xInput.type = 'number';
|
||||||
|
xInput.className = 'ctrl-input';
|
||||||
|
xInput.value = String(initialState.impedanceImag);
|
||||||
|
xInput.step = '1';
|
||||||
|
xInput.addEventListener('change', () => {
|
||||||
|
const v = parseFloat(xInput.value);
|
||||||
|
if (!isNaN(v)) callbacks.onImpedanceImagChange(v);
|
||||||
|
});
|
||||||
|
xGroup.appendChild(xInput);
|
||||||
|
impedRow.appendChild(xGroup);
|
||||||
|
|
||||||
|
container.appendChild(impedRow);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
container.appendChild(el('div', 'controls-spacer'));
|
||||||
|
|
||||||
|
const btnCompute = document.createElement('button');
|
||||||
|
btnCompute.className = 'ctrl-btn ctrl-btn-primary';
|
||||||
|
btnCompute.innerHTML = `${iconCrosshair} Compute`;
|
||||||
|
btnCompute.addEventListener('click', callbacks.onCompute);
|
||||||
|
container.appendChild(btnCompute);
|
||||||
|
|
||||||
|
const btnScan = document.createElement('button');
|
||||||
|
btnScan.className = 'ctrl-btn ctrl-btn-secondary';
|
||||||
|
btnScan.innerHTML = `${iconActivity} Scan & Visualize`;
|
||||||
|
btnScan.addEventListener('click', callbacks.onScan);
|
||||||
|
container.appendChild(btnScan);
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
container.appendChild(el('hr', 'controls-sep'));
|
||||||
|
|
||||||
|
// Display mode
|
||||||
|
container.appendChild(sectionTitle('Display', iconDisplay()));
|
||||||
|
|
||||||
|
const modeGroup = el('div', 'controls-mode-group');
|
||||||
|
DISPLAY_MODES.forEach((m) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'ctrl-mode-btn' + (m.value === initialState.displayMode ? ' active' : '');
|
||||||
|
btn.textContent = m.label;
|
||||||
|
btn.dataset.mode = m.value;
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
modeGroup.querySelectorAll('.ctrl-mode-btn').forEach((b) => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
callbacks.onDisplayModeChange(m.value);
|
||||||
|
});
|
||||||
|
modeGroup.appendChild(btn);
|
||||||
|
});
|
||||||
|
container.appendChild(modeGroup);
|
||||||
|
|
||||||
|
// Peak gain readout
|
||||||
|
container.appendChild(el('hr', 'controls-sep'));
|
||||||
|
const peakGainEl = el('div', 'controls-readout');
|
||||||
|
peakGainEl.innerHTML = peakGainHtml(null);
|
||||||
|
container.appendChild(peakGainEl);
|
||||||
|
|
||||||
|
// Loading overlay
|
||||||
|
const loadingEl = el('div', 'controls-loading');
|
||||||
|
loadingEl.textContent = '';
|
||||||
|
loadingEl.style.display = 'none';
|
||||||
|
container.appendChild(loadingEl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateStatus(connected: boolean) {
|
||||||
|
statusEl.innerHTML = statusHtml(connected);
|
||||||
|
},
|
||||||
|
updatePeakGain(dbi: number | null) {
|
||||||
|
peakGainEl.innerHTML = peakGainHtml(dbi);
|
||||||
|
},
|
||||||
|
updateLoading(loading: boolean) {
|
||||||
|
loadingEl.style.display = loading ? 'block' : 'none';
|
||||||
|
loadingEl.textContent = loading ? 'Computing...' : '';
|
||||||
|
btnCompute.disabled = loading;
|
||||||
|
btnScan.disabled = loading;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Helpers --
|
||||||
|
|
||||||
|
function el(tag: string, className: string): HTMLElement {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
e.className = className;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldGroup(label: string): HTMLElement {
|
||||||
|
const g = el('div', 'ctrl-field');
|
||||||
|
const lbl = document.createElement('label');
|
||||||
|
lbl.className = 'ctrl-label';
|
||||||
|
lbl.textContent = label;
|
||||||
|
g.appendChild(lbl);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionTitle(text: string, icon: string): HTMLElement {
|
||||||
|
const s = el('div', 'controls-section-title');
|
||||||
|
s.innerHTML = `${icon} ${text}`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusHtml(connected: boolean): string {
|
||||||
|
const icon = connected ? iconWifi : iconWifiOff;
|
||||||
|
const cls = connected ? 'status-on' : 'status-off';
|
||||||
|
const label = connected ? 'Connected' : 'Disconnected';
|
||||||
|
return `<span class="status-dot ${cls}"></span>${icon}<span>${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function peakGainHtml(dbi: number | null): string {
|
||||||
|
if (dbi === null) {
|
||||||
|
return `<span class="readout-label">Peak Gain</span><span class="readout-value">--</span>`;
|
||||||
|
}
|
||||||
|
return `<span class="readout-label">Peak Gain</span><span class="readout-value">${dbi.toFixed(1)} dBi</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconSettings(): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconDisplay(): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>`;
|
||||||
|
}
|
||||||
22
frontend/src/icons.ts
Normal file
22
frontend/src/icons.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Inline Lucide icon SVGs — no package dependency needed.
|
||||||
|
// Each returns an SVG string (24x24 viewBox, stroke-based).
|
||||||
|
|
||||||
|
const ATTRS = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
||||||
|
|
||||||
|
/** Radio / antenna icon */
|
||||||
|
export const iconRadio = `<svg ${ATTRS}><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>`;
|
||||||
|
|
||||||
|
/** Activity / waveform icon */
|
||||||
|
export const iconActivity = `<svg ${ATTRS}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`;
|
||||||
|
|
||||||
|
/** Settings / gear icon */
|
||||||
|
export const iconSettings = `<svg ${ATTRS}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
|
||||||
|
|
||||||
|
/** Wifi icon (status connected) */
|
||||||
|
export const iconWifi = `<svg ${ATTRS}><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>`;
|
||||||
|
|
||||||
|
/** WifiOff icon (status disconnected) */
|
||||||
|
export const iconWifiOff = `<svg ${ATTRS}><line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.56 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>`;
|
||||||
|
|
||||||
|
/** Crosshair / target icon */
|
||||||
|
export const iconCrosshair = `<svg ${ATTRS}><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>`;
|
||||||
151
frontend/src/main.ts
Normal file
151
frontend/src/main.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { createScene, type SceneContext } from './scene';
|
||||||
|
import { updatePattern } from './pattern';
|
||||||
|
import { createControls } from './controls';
|
||||||
|
import { drawSmithChart } from './smith';
|
||||||
|
import { fetchPattern, scanPattern, connectWebSocket } from './api';
|
||||||
|
import type { AppState, DisplayMode, PatternData } from './types';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const state: AppState = {
|
||||||
|
connected: false,
|
||||||
|
loading: false,
|
||||||
|
pattern: null,
|
||||||
|
displayMode: 'surface',
|
||||||
|
antennaType: 'dipole',
|
||||||
|
frequencyMhz: 146.0,
|
||||||
|
impedanceReal: 50,
|
||||||
|
impedanceImag: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sceneCtx: SceneContext;
|
||||||
|
let controlsUi: ReturnType<typeof createControls>;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (state.pattern && sceneCtx) {
|
||||||
|
updatePattern(sceneCtx, state.pattern, state.displayMode);
|
||||||
|
controlsUi.updatePeakGain(state.pattern.peak_gain_dbi);
|
||||||
|
|
||||||
|
// Update Smith chart
|
||||||
|
const smithCanvas = document.getElementById('smith-chart') as HTMLCanvasElement | null;
|
||||||
|
if (smithCanvas) {
|
||||||
|
drawSmithChart(smithCanvas, state.pattern.resonance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePatternData(data: PatternData) {
|
||||||
|
state.pattern = data;
|
||||||
|
state.loading = false;
|
||||||
|
controlsUi.updateLoading(false);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompute() {
|
||||||
|
state.loading = true;
|
||||||
|
controlsUi.updateLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchPattern({
|
||||||
|
antenna_type: state.antennaType,
|
||||||
|
frequency_hz: state.frequencyMhz * 1e6,
|
||||||
|
impedance_real: state.impedanceReal,
|
||||||
|
impedance_imag: state.impedanceImag,
|
||||||
|
});
|
||||||
|
handlePatternData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Compute error:', err);
|
||||||
|
state.loading = false;
|
||||||
|
controlsUi.updateLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
state.loading = true;
|
||||||
|
controlsUi.updateLoading(true);
|
||||||
|
const centerHz = state.frequencyMhz * 1e6;
|
||||||
|
const span = centerHz * 0.1; // 10% span
|
||||||
|
try {
|
||||||
|
const data = await scanPattern({
|
||||||
|
antenna_type: state.antennaType,
|
||||||
|
start_hz: centerHz - span / 2,
|
||||||
|
stop_hz: centerHz + span / 2,
|
||||||
|
points: 101,
|
||||||
|
});
|
||||||
|
handlePatternData(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Scan error:', err);
|
||||||
|
state.loading = false;
|
||||||
|
controlsUi.updateLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
const sceneContainer = document.getElementById('scene-container');
|
||||||
|
const controlsContainer = document.getElementById('controls');
|
||||||
|
const smithCanvas = document.getElementById('smith-chart') as HTMLCanvasElement | null;
|
||||||
|
|
||||||
|
if (!sceneContainer || !controlsContainer) {
|
||||||
|
console.error('Missing DOM containers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three.js scene
|
||||||
|
sceneCtx = createScene(sceneContainer);
|
||||||
|
|
||||||
|
// Controls panel
|
||||||
|
controlsUi = createControls(controlsContainer, {
|
||||||
|
onCompute: handleCompute,
|
||||||
|
onScan: handleScan,
|
||||||
|
onDisplayModeChange(mode: DisplayMode) {
|
||||||
|
state.displayMode = mode;
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
onAntennaTypeChange(type: string) {
|
||||||
|
state.antennaType = type;
|
||||||
|
},
|
||||||
|
onFrequencyChange(mhz: number) {
|
||||||
|
state.frequencyMhz = mhz;
|
||||||
|
},
|
||||||
|
onImpedanceRealChange(r: number) {
|
||||||
|
state.impedanceReal = r;
|
||||||
|
},
|
||||||
|
onImpedanceImagChange(x: number) {
|
||||||
|
state.impedanceImag = x;
|
||||||
|
},
|
||||||
|
}, state);
|
||||||
|
|
||||||
|
// Initial Smith chart
|
||||||
|
if (smithCanvas) {
|
||||||
|
drawSmithChart(smithCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket for live updates
|
||||||
|
connectWebSocket(handlePatternData, (connected) => {
|
||||||
|
state.connected = connected;
|
||||||
|
controlsUi.updateStatus(connected);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show loading message initially
|
||||||
|
showLoadingMessage(sceneContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingMessage(container: HTMLElement) {
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'scene-loading';
|
||||||
|
msg.innerHTML = `
|
||||||
|
<div class="scene-loading-content">
|
||||||
|
<div class="scene-loading-spinner"></div>
|
||||||
|
<p>Press <strong>Compute</strong> to generate a pattern,<br/>or <strong>Scan & Visualize</strong> with a connected VNA.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(msg);
|
||||||
|
|
||||||
|
// Remove after first pattern
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (state.pattern) {
|
||||||
|
msg.remove();
|
||||||
|
clearInterval(check);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
231
frontend/src/pattern.ts
Normal file
231
frontend/src/pattern.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import type { SceneContext } from './scene';
|
||||||
|
import type { PatternData, DisplayMode, PlanePoint } from './types';
|
||||||
|
|
||||||
|
const TEAL_400 = new THREE.Color(0x2dd4bf);
|
||||||
|
const AMBER_400 = new THREE.Color(0xfbbf24);
|
||||||
|
|
||||||
|
// HSL interpolation from teal-400 to amber-400
|
||||||
|
function gainColor(t: number): THREE.Color {
|
||||||
|
const hslA = { h: 0, s: 0, l: 0 };
|
||||||
|
const hslB = { h: 0, s: 0, l: 0 };
|
||||||
|
TEAL_400.getHSL(hslA);
|
||||||
|
AMBER_400.getHSL(hslB);
|
||||||
|
|
||||||
|
const clamped = Math.max(0, Math.min(1, t));
|
||||||
|
|
||||||
|
// Interpolate in HSL — handle hue wrap
|
||||||
|
let dh = hslB.h - hslA.h;
|
||||||
|
if (dh > 0.5) dh -= 1;
|
||||||
|
if (dh < -0.5) dh += 1;
|
||||||
|
|
||||||
|
const h = hslA.h + dh * clamped;
|
||||||
|
const s = hslA.s + (hslB.s - hslA.s) * clamped;
|
||||||
|
const l = hslA.l + (hslB.l - hslA.l) * clamped;
|
||||||
|
|
||||||
|
return new THREE.Color().setHSL(((h % 1) + 1) % 1, s, l);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGain(dbi: number, minDbi: number, maxDbi: number): number {
|
||||||
|
if (maxDbi === minDbi) return 0.55;
|
||||||
|
const t = (dbi - minDbi) / (maxDbi - minDbi);
|
||||||
|
return 0.1 + Math.max(0, Math.min(1, t)) * 0.9; // maps to [0.1, 1.0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deg2rad(deg: number): number {
|
||||||
|
return (deg * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build 3D sphere mesh from theta/phi/gain data. */
|
||||||
|
function buildPatternGeometry(
|
||||||
|
data: PatternData,
|
||||||
|
baseRadius: number
|
||||||
|
): { geometry: THREE.BufferGeometry; minGain: number; maxGain: number } {
|
||||||
|
const { theta_deg, phi_deg, gain_dbi } = data;
|
||||||
|
const nTheta = theta_deg.length;
|
||||||
|
const nPhi = phi_deg.length;
|
||||||
|
|
||||||
|
// Flatten gain to find range
|
||||||
|
let minGain = Infinity;
|
||||||
|
let maxGain = -Infinity;
|
||||||
|
for (let ti = 0; ti < nTheta; ti++) {
|
||||||
|
for (let pi = 0; pi < nPhi; pi++) {
|
||||||
|
const g = gain_dbi[ti][pi];
|
||||||
|
if (g < minGain) minGain = g;
|
||||||
|
if (g > maxGain) maxGain = g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build vertices and colors
|
||||||
|
const positions: number[] = [];
|
||||||
|
const colors: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
|
||||||
|
// Vertex grid: nTheta rows x nPhi columns
|
||||||
|
for (let ti = 0; ti < nTheta; ti++) {
|
||||||
|
const theta = deg2rad(theta_deg[ti]);
|
||||||
|
for (let pi = 0; pi < nPhi; pi++) {
|
||||||
|
const phi = deg2rad(phi_deg[pi]);
|
||||||
|
const g = gain_dbi[ti][pi];
|
||||||
|
const normG = normalizeGain(g, minGain, maxGain);
|
||||||
|
const r = baseRadius * normG;
|
||||||
|
|
||||||
|
// Spherical to Cartesian (physics convention: theta=polar, phi=azimuthal)
|
||||||
|
const x = r * Math.sin(theta) * Math.cos(phi);
|
||||||
|
const y = r * Math.cos(theta);
|
||||||
|
const z = r * Math.sin(theta) * Math.sin(phi);
|
||||||
|
|
||||||
|
positions.push(x, y, z);
|
||||||
|
|
||||||
|
const color = gainColor(normG);
|
||||||
|
colors.push(color.r, color.g, color.b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Triangle indices (quads split into 2 triangles)
|
||||||
|
for (let ti = 0; ti < nTheta - 1; ti++) {
|
||||||
|
for (let pi = 0; pi < nPhi - 1; pi++) {
|
||||||
|
const a = ti * nPhi + pi;
|
||||||
|
const b = ti * nPhi + (pi + 1);
|
||||||
|
const c = (ti + 1) * nPhi + pi;
|
||||||
|
const d = (ti + 1) * nPhi + (pi + 1);
|
||||||
|
|
||||||
|
indices.push(a, b, d);
|
||||||
|
indices.push(a, d, c);
|
||||||
|
}
|
||||||
|
// Wrap phi: connect last column to first
|
||||||
|
const a = ti * nPhi + (nPhi - 1);
|
||||||
|
const b = ti * nPhi;
|
||||||
|
const c = (ti + 1) * nPhi + (nPhi - 1);
|
||||||
|
const d = (ti + 1) * nPhi;
|
||||||
|
|
||||||
|
indices.push(a, b, d);
|
||||||
|
indices.push(a, d, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
|
||||||
|
return { geometry, minGain, maxGain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a plane-cut line from PlanePoint[] data. */
|
||||||
|
function buildPlaneLine(
|
||||||
|
points: PlanePoint[],
|
||||||
|
isEPlane: boolean,
|
||||||
|
minGain: number,
|
||||||
|
maxGain: number,
|
||||||
|
baseRadius: number
|
||||||
|
): THREE.Line {
|
||||||
|
const linePoints: THREE.Vector3[] = [];
|
||||||
|
const lineColors: number[] = [];
|
||||||
|
|
||||||
|
points.forEach((pt) => {
|
||||||
|
const angleDeg = isEPlane ? (pt.theta_deg ?? 0) : (pt.phi_deg ?? 0);
|
||||||
|
const angle = deg2rad(angleDeg);
|
||||||
|
const normG = normalizeGain(pt.gain_dbi, minGain, maxGain);
|
||||||
|
const r = baseRadius * normG;
|
||||||
|
|
||||||
|
let x: number, y: number, z: number;
|
||||||
|
if (isEPlane) {
|
||||||
|
// E-plane: varies theta at phi=0, shown in XY plane
|
||||||
|
x = r * Math.sin(angle);
|
||||||
|
y = r * Math.cos(angle);
|
||||||
|
z = 0;
|
||||||
|
} else {
|
||||||
|
// H-plane: varies phi at theta=90, shown in XZ plane
|
||||||
|
x = r * Math.cos(angle);
|
||||||
|
y = 0;
|
||||||
|
z = r * Math.sin(angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
linePoints.push(new THREE.Vector3(x, y, z));
|
||||||
|
const color = gainColor(normG);
|
||||||
|
lineColors.push(color.r, color.g, color.b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the loop
|
||||||
|
if (linePoints.length > 0) {
|
||||||
|
linePoints.push(linePoints[0].clone());
|
||||||
|
lineColors.push(lineColors[0], lineColors[1], lineColors[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints(linePoints);
|
||||||
|
geo.setAttribute('color', new THREE.Float32BufferAttribute(lineColors, 3));
|
||||||
|
|
||||||
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
linewidth: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new THREE.Line(geo, mat);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePattern(ctx: SceneContext, data: PatternData, mode: DisplayMode): void {
|
||||||
|
// Clear previous
|
||||||
|
while (ctx.patternGroup.children.length > 0) {
|
||||||
|
const child = ctx.patternGroup.children[0];
|
||||||
|
ctx.patternGroup.remove(child);
|
||||||
|
if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((m) => m.dispose());
|
||||||
|
} else {
|
||||||
|
child.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRadius = 2.0;
|
||||||
|
const { geometry, minGain, maxGain } = buildPatternGeometry(data, baseRadius);
|
||||||
|
|
||||||
|
if (mode === 'surface') {
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
shininess: 40,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.85,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
ctx.patternGroup.add(mesh);
|
||||||
|
} else if (mode === 'wireframe') {
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
wireframe: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
ctx.patternGroup.add(mesh);
|
||||||
|
} else if (mode === 'e-plane') {
|
||||||
|
if (data.e_plane && data.e_plane.length > 0) {
|
||||||
|
const line = buildPlaneLine(data.e_plane, true, minGain, maxGain, baseRadius);
|
||||||
|
ctx.patternGroup.add(line);
|
||||||
|
}
|
||||||
|
// Also show a dim surface for context
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.15,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
ctx.patternGroup.add(mesh);
|
||||||
|
} else if (mode === 'h-plane') {
|
||||||
|
if (data.h_plane && data.h_plane.length > 0) {
|
||||||
|
const line = buildPlaneLine(data.h_plane, false, minGain, maxGain, baseRadius);
|
||||||
|
ctx.patternGroup.add(line);
|
||||||
|
}
|
||||||
|
const material = new THREE.MeshPhongMaterial({
|
||||||
|
vertexColors: true,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.15,
|
||||||
|
});
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
ctx.patternGroup.add(mesh);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
frontend/src/scene.ts
Normal file
176
frontend/src/scene.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||||
|
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
||||||
|
|
||||||
|
const BG_COLOR = 0x0f172a;
|
||||||
|
const GRID_COLOR = 0x334155; // slate-700
|
||||||
|
const RING_COLOR = 0x475569; // slate-600
|
||||||
|
const LABEL_COLOR = '#94a3b8'; // slate-400
|
||||||
|
|
||||||
|
export interface SceneContext {
|
||||||
|
scene: THREE.Scene;
|
||||||
|
camera: THREE.PerspectiveCamera;
|
||||||
|
renderer: THREE.WebGLRenderer;
|
||||||
|
labelRenderer: CSS2DRenderer;
|
||||||
|
controls: OrbitControls;
|
||||||
|
patternGroup: THREE.Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createScene(container: HTMLElement): SceneContext {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(BG_COLOR);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
const aspect = container.clientWidth / container.clientHeight;
|
||||||
|
const camera = new THREE.PerspectiveCamera(55, aspect, 0.1, 100);
|
||||||
|
camera.position.set(3, 2.5, 3);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// WebGL renderer
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// CSS2D renderer for labels
|
||||||
|
const labelRenderer = new CSS2DRenderer();
|
||||||
|
labelRenderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
labelRenderer.domElement.style.position = 'absolute';
|
||||||
|
labelRenderer.domElement.style.top = '0';
|
||||||
|
labelRenderer.domElement.style.left = '0';
|
||||||
|
labelRenderer.domElement.style.pointerEvents = 'none';
|
||||||
|
container.appendChild(labelRenderer.domElement);
|
||||||
|
|
||||||
|
// Orbit controls
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.minDistance = 1.5;
|
||||||
|
controls.maxDistance = 12;
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
dirLight.position.set(5, 8, 5);
|
||||||
|
scene.add(dirLight);
|
||||||
|
|
||||||
|
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3);
|
||||||
|
dirLight2.position.set(-3, -2, -4);
|
||||||
|
scene.add(dirLight2);
|
||||||
|
|
||||||
|
// Grid on XZ plane
|
||||||
|
const gridHelper = new THREE.GridHelper(6, 12, GRID_COLOR, GRID_COLOR);
|
||||||
|
(gridHelper.material as THREE.Material).opacity = 0.3;
|
||||||
|
(gridHelper.material as THREE.Material).transparent = true;
|
||||||
|
scene.add(gridHelper);
|
||||||
|
|
||||||
|
// Axis lines
|
||||||
|
addAxisLines(scene);
|
||||||
|
|
||||||
|
// Axis labels
|
||||||
|
addAxisLabel(scene, 'X', new THREE.Vector3(3.3, 0, 0));
|
||||||
|
addAxisLabel(scene, 'Y', new THREE.Vector3(0, 3.3, 0));
|
||||||
|
addAxisLabel(scene, 'Z', new THREE.Vector3(0, 0, 3.3));
|
||||||
|
|
||||||
|
// Reference dBi rings on XZ plane
|
||||||
|
addReferenceRings(scene);
|
||||||
|
|
||||||
|
// Group for pattern meshes
|
||||||
|
const patternGroup = new THREE.Group();
|
||||||
|
scene.add(patternGroup);
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
const onResize = () => {
|
||||||
|
const w = container.clientWidth;
|
||||||
|
const h = container.clientHeight;
|
||||||
|
camera.aspect = w / h;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(w, h);
|
||||||
|
labelRenderer.setSize(w, h);
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
|
||||||
|
// Animation loop
|
||||||
|
const animate = () => {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
labelRenderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
return { scene, camera, renderer, labelRenderer, controls, patternGroup };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAxisLines(scene: THREE.Scene) {
|
||||||
|
const len = 3.2;
|
||||||
|
|
||||||
|
const xGeo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, 0, 0),
|
||||||
|
new THREE.Vector3(len, 0, 0),
|
||||||
|
]);
|
||||||
|
const xMat = new THREE.LineBasicMaterial({ color: 0xef4444, opacity: 0.6, transparent: true });
|
||||||
|
scene.add(new THREE.Line(xGeo, xMat));
|
||||||
|
|
||||||
|
const yGeo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, 0, 0),
|
||||||
|
new THREE.Vector3(0, len, 0),
|
||||||
|
]);
|
||||||
|
const yMat = new THREE.LineBasicMaterial({ color: 0x22c55e, opacity: 0.6, transparent: true });
|
||||||
|
scene.add(new THREE.Line(yGeo, yMat));
|
||||||
|
|
||||||
|
const zGeo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, 0, 0),
|
||||||
|
new THREE.Vector3(0, 0, len),
|
||||||
|
]);
|
||||||
|
const zMat = new THREE.LineBasicMaterial({ color: 0x3b82f6, opacity: 0.6, transparent: true });
|
||||||
|
scene.add(new THREE.Line(zGeo, zMat));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAxisLabel(scene: THREE.Scene, text: string, position: THREE.Vector3) {
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.color = LABEL_COLOR;
|
||||||
|
el.style.fontFamily = "'Inter', sans-serif";
|
||||||
|
el.style.fontSize = '12px';
|
||||||
|
el.style.fontWeight = '600';
|
||||||
|
const label = new CSS2DObject(el);
|
||||||
|
label.position.copy(position);
|
||||||
|
scene.add(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addReferenceRings(scene: THREE.Scene) {
|
||||||
|
const dbiLevels = [-3, 0, 3, 6];
|
||||||
|
const minDbi = -3;
|
||||||
|
const maxDbi = 6;
|
||||||
|
|
||||||
|
dbiLevels.forEach((dbi) => {
|
||||||
|
const normalized = (dbi - minDbi) / (maxDbi - minDbi);
|
||||||
|
const radius = 0.1 + normalized * 1.9; // map to [0.1, 2.0]
|
||||||
|
|
||||||
|
const curve = new THREE.EllipseCurve(0, 0, radius, radius, 0, 2 * Math.PI, false, 0);
|
||||||
|
const points = curve.getPoints(64);
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints(
|
||||||
|
points.map((p) => new THREE.Vector3(p.x, 0, p.y))
|
||||||
|
);
|
||||||
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
color: RING_COLOR,
|
||||||
|
opacity: 0.35,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const ring = new THREE.Line(geo, mat);
|
||||||
|
scene.add(ring);
|
||||||
|
|
||||||
|
// Label for the ring
|
||||||
|
const el = document.createElement('span');
|
||||||
|
el.textContent = `${dbi >= 0 ? '+' : ''}${dbi} dBi`;
|
||||||
|
el.style.color = '#64748b'; // slate-500
|
||||||
|
el.style.fontFamily = "'Inter', sans-serif";
|
||||||
|
el.style.fontSize = '10px';
|
||||||
|
const label = new CSS2DObject(el);
|
||||||
|
label.position.set(radius + 0.1, 0, 0);
|
||||||
|
scene.add(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
154
frontend/src/smith.ts
Normal file
154
frontend/src/smith.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import type { ResonanceData } from './types';
|
||||||
|
|
||||||
|
const GRID_COLOR = '#475569'; // slate-600
|
||||||
|
const POINT_COLOR = '#2dd4bf'; // teal-400
|
||||||
|
const BG_COLOR = '#1e293b'; // slate-800
|
||||||
|
const BORDER_COLOR = '#334155'; // slate-700
|
||||||
|
const TEXT_COLOR = '#94a3b8'; // slate-400
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw the Smith chart grid on a 2D canvas.
|
||||||
|
* Uses normalized impedance: z = Z/Z0 where Z0=50.
|
||||||
|
* Smith chart maps z to reflection coefficient Gamma = (z-1)/(z+1).
|
||||||
|
*/
|
||||||
|
export function drawSmithChart(canvas: HTMLCanvasElement, resonance?: ResonanceData): void {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const R = (Math.min(w, h) / 2) * 0.85; // chart radius in pixels
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.fillStyle = BG_COLOR;
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = BORDER_COLOR;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeRect(0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Constant R circles
|
||||||
|
ctx.strokeStyle = GRID_COLOR;
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
const rValues = [0, 0.2, 0.5, 1, 2, 5];
|
||||||
|
for (const r of rValues) {
|
||||||
|
// Circle center at ((r/(r+1))*R + cx, cy), radius R/(r+1)
|
||||||
|
const circR = R / (r + 1);
|
||||||
|
const circX = cx + (r / (r + 1)) * R;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(circX, cy, circR, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant X arcs
|
||||||
|
const xValues = [0.2, 0.5, 1, 2, 5];
|
||||||
|
for (const x of xValues) {
|
||||||
|
// Positive X arc (inductive, above center)
|
||||||
|
drawXArc(ctx, cx, cy, R, x);
|
||||||
|
// Negative X arc (capacitive, below center)
|
||||||
|
drawXArc(ctx, cx, cy, R, -x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal center line
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - R, cy);
|
||||||
|
ctx.lineTo(cx + R, cy);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Outer circle
|
||||||
|
ctx.strokeStyle = GRID_COLOR;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, R, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
ctx.fillStyle = TEXT_COLOR;
|
||||||
|
ctx.font = "10px 'Inter', sans-serif";
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('Smith Chart', cx, h - 4);
|
||||||
|
|
||||||
|
// Plot impedance point if we have resonance data
|
||||||
|
if (resonance) {
|
||||||
|
const z_real = resonance.impedance_real / 50;
|
||||||
|
const z_imag = resonance.impedance_imag / 50;
|
||||||
|
|
||||||
|
// Reflection coefficient: Gamma = (z-1)/(z+1) where z = z_real + j*z_imag
|
||||||
|
const denom_r = (z_real + 1) * (z_real + 1) + z_imag * z_imag;
|
||||||
|
const gamma_real = ((z_real * z_real + z_imag * z_imag - 1)) / denom_r;
|
||||||
|
const gamma_imag = (2 * z_imag) / denom_r;
|
||||||
|
|
||||||
|
const px = cx + gamma_real * R;
|
||||||
|
const py = cy - gamma_imag * R; // y-axis inverted in canvas
|
||||||
|
|
||||||
|
// Draw point
|
||||||
|
ctx.fillStyle = POINT_COLOR;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 4, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Outline
|
||||||
|
ctx.strokeStyle = '#ffffff';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 4, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = POINT_COLOR;
|
||||||
|
ctx.font = "bold 9px 'Inter', sans-serif";
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
const label = `${resonance.impedance_real.toFixed(0)}${resonance.impedance_imag >= 0 ? '+' : ''}${resonance.impedance_imag.toFixed(0)}j`;
|
||||||
|
ctx.fillText(label, px + 7, py + 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawXArc(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
R: number,
|
||||||
|
x: number
|
||||||
|
): void {
|
||||||
|
const arcR = R / Math.abs(x);
|
||||||
|
const centerX = cx + R;
|
||||||
|
const centerY = x > 0 ? cy - arcR : cy + arcR;
|
||||||
|
|
||||||
|
// We need to clip the arc to within the unit circle.
|
||||||
|
// Use many small segments and only draw those inside.
|
||||||
|
const steps = 100;
|
||||||
|
ctx.beginPath();
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const t = (i / steps) * Math.PI;
|
||||||
|
const angle = x > 0 ? -Math.PI / 2 + t : Math.PI / 2 - t;
|
||||||
|
const px = centerX + arcR * Math.cos(angle);
|
||||||
|
const py = centerY + arcR * Math.sin(angle);
|
||||||
|
|
||||||
|
// Check if inside unit circle
|
||||||
|
const dx = px - cx;
|
||||||
|
const dy = py - cy;
|
||||||
|
if (dx * dx + dy * dy <= R * R * 1.01) {
|
||||||
|
if (!drawing) {
|
||||||
|
ctx.moveTo(px, py);
|
||||||
|
drawing = true;
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(px, py);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drawing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
393
frontend/src/style.css
Normal file
393
frontend/src/style.css
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
/* === Base === */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--slate-50: #f8fafc;
|
||||||
|
--slate-100: #f1f5f9;
|
||||||
|
--slate-200: #e2e8f0;
|
||||||
|
--slate-300: #cbd5e1;
|
||||||
|
--slate-400: #94a3b8;
|
||||||
|
--slate-500: #64748b;
|
||||||
|
--slate-600: #475569;
|
||||||
|
--slate-700: #334155;
|
||||||
|
--slate-800: #1e293b;
|
||||||
|
--slate-900: #0f172a;
|
||||||
|
--slate-950: #020617;
|
||||||
|
--teal-400: #2dd4bf;
|
||||||
|
--teal-500: #14b8a6;
|
||||||
|
--amber-400: #fbbf24;
|
||||||
|
--amber-500: #f59e0b;
|
||||||
|
--red-400: #f87171;
|
||||||
|
--green-400: #4ade80;
|
||||||
|
|
||||||
|
--panel-width: 280px;
|
||||||
|
--panel-bg: rgba(15, 23, 42, 0.88);
|
||||||
|
--panel-border: rgba(51, 65, 85, 0.5);
|
||||||
|
--radius: 8px;
|
||||||
|
--radius-sm: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--slate-300);
|
||||||
|
background: var(--slate-900);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Layout === */
|
||||||
|
#app {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scene-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#smith-chart {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Controls Panel === */
|
||||||
|
.controls-panel {
|
||||||
|
width: var(--panel-width);
|
||||||
|
min-width: var(--panel-width);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-right: 1px solid var(--panel-border);
|
||||||
|
padding: 20px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-header {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--slate-100);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-logo svg {
|
||||||
|
color: var(--teal-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-subtitle {
|
||||||
|
color: var(--slate-500);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 2px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.controls-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--slate-400);
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-status svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-on {
|
||||||
|
background: var(--green-400);
|
||||||
|
box-shadow: 0 0 6px rgba(74, 222, 128, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-off {
|
||||||
|
background: var(--slate-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Separators */
|
||||||
|
.controls-sep {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--panel-border);
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section titles */
|
||||||
|
.controls-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--slate-400);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section-title svg {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.ctrl-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--slate-500);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-input,
|
||||||
|
.ctrl-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--slate-200);
|
||||||
|
background: var(--slate-800);
|
||||||
|
border: 1px solid var(--slate-700);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-input:focus,
|
||||||
|
.ctrl-select:focus {
|
||||||
|
border-color: var(--teal-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-input::placeholder {
|
||||||
|
color: var(--slate-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Row layout */
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacer */
|
||||||
|
.controls-spacer {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.ctrl-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-primary {
|
||||||
|
background: var(--teal-500);
|
||||||
|
color: var(--slate-950);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--teal-400);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--slate-300);
|
||||||
|
border-color: var(--slate-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-btn-secondary:hover:not(:disabled) {
|
||||||
|
border-color: var(--slate-400);
|
||||||
|
color: var(--slate-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Display mode toggle group */
|
||||||
|
.controls-mode-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-mode-btn {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--slate-400);
|
||||||
|
background: var(--slate-800);
|
||||||
|
border: 1px solid var(--slate-700);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-mode-btn:hover {
|
||||||
|
color: var(--slate-200);
|
||||||
|
border-color: var(--slate-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctrl-mode-btn.active {
|
||||||
|
color: var(--teal-400);
|
||||||
|
border-color: var(--teal-400);
|
||||||
|
background: rgba(45, 212, 191, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Readout */
|
||||||
|
.controls-readout {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(30, 41, 59, 0.6);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readout-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--slate-500);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readout-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--amber-400);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading overlay for controls */
|
||||||
|
.controls-loading {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--teal-400);
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px;
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scene loading overlay */
|
||||||
|
.scene-loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-content {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--slate-500);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-content strong {
|
||||||
|
color: var(--slate-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene-loading-spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border: 3px solid var(--slate-700);
|
||||||
|
border-top-color: var(--teal-400);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
.controls-panel::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-panel::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-panel::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--slate-700);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
54
frontend/src/types.ts
Normal file
54
frontend/src/types.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
export interface PatternData {
|
||||||
|
antenna_type: string;
|
||||||
|
frequency_hz: number;
|
||||||
|
wavelength_m: number;
|
||||||
|
theta_deg: number[];
|
||||||
|
phi_deg: number[];
|
||||||
|
gain_dbi: number[][];
|
||||||
|
peak_gain_dbi: number;
|
||||||
|
e_plane: PlanePoint[];
|
||||||
|
h_plane: PlanePoint[];
|
||||||
|
model: string;
|
||||||
|
resonance?: ResonanceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanePoint {
|
||||||
|
theta_deg?: number;
|
||||||
|
phi_deg?: number;
|
||||||
|
gain_dbi: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResonanceData {
|
||||||
|
frequency_hz: number;
|
||||||
|
swr: number;
|
||||||
|
return_loss_db: number;
|
||||||
|
impedance_real: number;
|
||||||
|
impedance_imag: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputeParams {
|
||||||
|
antenna_type: string;
|
||||||
|
frequency_hz: number;
|
||||||
|
impedance_real: number;
|
||||||
|
impedance_imag: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanParams {
|
||||||
|
antenna_type: string;
|
||||||
|
start_hz: number;
|
||||||
|
stop_hz: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayMode = 'surface' | 'wireframe' | 'e-plane' | 'h-plane';
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
connected: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
pattern: PatternData | null;
|
||||||
|
displayMode: DisplayMode;
|
||||||
|
antennaType: string;
|
||||||
|
frequencyMhz: number;
|
||||||
|
impedanceReal: number;
|
||||||
|
impedanceImag: number;
|
||||||
|
}
|
||||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
17
frontend/vite.config.ts
Normal file
17
frontend/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: '../src/mcnanovna/webui/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
'/ws': {
|
||||||
|
target: 'ws://localhost:8080',
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcnanovna"
|
name = "mcnanovna"
|
||||||
version = "2026.01.30"
|
version = "2026.01.31"
|
||||||
description = "MCP server for NanoVNA-H vector network analyzers"
|
description = "MCP server for NanoVNA-H vector network analyzers"
|
||||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@ -10,6 +10,12 @@ dependencies = [
|
|||||||
"Pillow>=11.0.0",
|
"Pillow>=11.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
webui = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.34.0",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mcnanovna = "mcnanovna.server:main"
|
mcnanovna = "mcnanovna.server:main"
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ from mcnanovna.tools import (
|
|||||||
DiagnosticsMixin,
|
DiagnosticsMixin,
|
||||||
DisplayMixin,
|
DisplayMixin,
|
||||||
MeasurementMixin,
|
MeasurementMixin,
|
||||||
|
RadiationMixin,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ class NanoVNA(
|
|||||||
DeviceMixin,
|
DeviceMixin,
|
||||||
DiagnosticsMixin,
|
DiagnosticsMixin,
|
||||||
AnalysisMixin,
|
AnalysisMixin,
|
||||||
|
RadiationMixin,
|
||||||
):
|
):
|
||||||
"""MCP tool class for NanoVNA-H vector network analyzers.
|
"""MCP tool class for NanoVNA-H vector network analyzers.
|
||||||
|
|
||||||
|
|||||||
@ -723,6 +723,88 @@ Let me run `analyze_lc_shunt` now.""",
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@mcp.prompt
|
||||||
|
def visualize_radiation_pattern(
|
||||||
|
antenna_type: str = "dipole",
|
||||||
|
band: str = "2m",
|
||||||
|
start_hz: int | None = None,
|
||||||
|
stop_hz: int | None = None,
|
||||||
|
points: int = 101,
|
||||||
|
) -> list[Message]:
|
||||||
|
"""Guide through 3D antenna radiation pattern visualization.
|
||||||
|
|
||||||
|
Scans S11 to determine resonant frequency and impedance, then computes
|
||||||
|
an analytical 3D radiation pattern for the specified antenna type.
|
||||||
|
If the web UI is running (MCNANOVNA_WEB_PORT), the pattern is pushed
|
||||||
|
to the 3D viewer in real time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
|
||||||
|
band: Ham band name (e.g., '2m', '70cm', '20m') or 'custom'
|
||||||
|
start_hz: Start frequency in Hz (overrides band)
|
||||||
|
stop_hz: Stop frequency in Hz (overrides band)
|
||||||
|
points: Number of measurement points
|
||||||
|
"""
|
||||||
|
if start_hz is not None and stop_hz is not None:
|
||||||
|
f_start, f_stop = start_hz, stop_hz
|
||||||
|
band_label = f"Custom ({_format_freq(f_start)} – {_format_freq(f_stop)})"
|
||||||
|
elif band in HAM_BANDS:
|
||||||
|
f_start, f_stop = HAM_BANDS[band]
|
||||||
|
band_label = f"{band.upper()} band"
|
||||||
|
else:
|
||||||
|
f_start, f_stop = HAM_BANDS["2m"]
|
||||||
|
band_label = "2M band"
|
||||||
|
|
||||||
|
type_label = antenna_type if antenna_type != "auto" else "auto-detected"
|
||||||
|
|
||||||
|
return [
|
||||||
|
Message(
|
||||||
|
role="user",
|
||||||
|
content=(
|
||||||
|
f"Visualize the radiation pattern of my {type_label} antenna "
|
||||||
|
f"on the {band_label} ({_format_freq(f_start)} – {_format_freq(f_stop)})."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
role="assistant",
|
||||||
|
content=f"""I'll generate a 3D radiation pattern for your antenna.
|
||||||
|
|
||||||
|
**Antenna type**: {type_label}
|
||||||
|
**Band**: {band_label} ({_format_freq(f_start)} – {_format_freq(f_stop)})
|
||||||
|
**Points**: {points}
|
||||||
|
|
||||||
|
**Supported antenna models** (Phase 1 — analytical patterns):
|
||||||
|
| Type | Pattern shape | Feed-point Z at resonance |
|
||||||
|
|------|--------------|--------------------------|
|
||||||
|
| Dipole | Figure-8 (donut) | ~73 Ω |
|
||||||
|
| Monopole | Half-donut over ground | ~36 Ω |
|
||||||
|
| EFHW | Same as dipole | ~2500-5000 Ω |
|
||||||
|
| Small loop | Figure-8 (rotated 90°) | Low R, inductive |
|
||||||
|
| Patch | Broadside hemisphere | ~50-300 Ω |
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. Scan S11 across the band to find resonant frequency and impedance
|
||||||
|
2. {"Estimate antenna type from impedance" if antenna_type == "auto" else f"Use the '{antenna_type}' analytical model"}
|
||||||
|
3. Compute 3D gain pattern on a θ×φ grid (~6500 points)
|
||||||
|
4. Return gain in dBi at each (θ, φ) — ready for 3D rendering
|
||||||
|
|
||||||
|
**Output includes:**
|
||||||
|
- Full 3D gain grid (θ: 0-180°, φ: 0-355°)
|
||||||
|
- Peak gain in dBi
|
||||||
|
- E-plane cut (φ=0°) and H-plane cut (θ=90°)
|
||||||
|
- S11 analysis context (resonance, impedance, SWR)
|
||||||
|
|
||||||
|
**Web UI** (if running):
|
||||||
|
Set `MCNANOVNA_WEB_PORT=8080` to launch the 3D viewer at http://localhost:8080.
|
||||||
|
The pattern renders as an interactive gain-mapped sphere with OrbitControls.
|
||||||
|
|
||||||
|
**No hardware?** Use `radiation_pattern_from_data` to compute a pattern from
|
||||||
|
known impedance values — no VNA connection required.
|
||||||
|
|
||||||
|
Let me scan and generate the pattern now.""",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@mcp.prompt
|
@mcp.prompt
|
||||||
def impedance_match(
|
def impedance_match(
|
||||||
frequency_hz: int = 145_000_000,
|
frequency_hz: int = 145_000_000,
|
||||||
|
|||||||
423
src/mcnanovna/radiation.py
Normal file
423
src/mcnanovna/radiation.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
"""Antenna radiation pattern models driven by S11 measurements.
|
||||||
|
|
||||||
|
Phase 1: Analytical models using closed-form equations for common antenna types.
|
||||||
|
S11 gives us impedance + resonant frequency; combined with user-specified antenna
|
||||||
|
type, we compute idealized 3D radiation patterns.
|
||||||
|
|
||||||
|
All functions are pure Python (math + list comprehensions). No external dependencies.
|
||||||
|
Grid size is typically 91x72 = 6552 points — trivial without numpy.
|
||||||
|
|
||||||
|
Phase 2 hooks: generate_3d_pattern() returns standardized {theta, phi, gain_dbi}
|
||||||
|
dicts. Future measured-pattern tools will produce the same format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Speed of light in m/s
|
||||||
|
C = 299_792_458
|
||||||
|
|
||||||
|
|
||||||
|
def _deg_range(start: float, stop: float, step: float) -> list[float]:
|
||||||
|
"""Generate a list of angles in degrees from start to stop (inclusive)."""
|
||||||
|
result = []
|
||||||
|
val = start
|
||||||
|
while val <= stop + step * 0.01:
|
||||||
|
result.append(val)
|
||||||
|
val += step
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _directivity_to_dbi(directivity: float) -> float:
|
||||||
|
"""Convert linear directivity to dBi."""
|
||||||
|
if directivity <= 0:
|
||||||
|
return -40.0
|
||||||
|
return 10.0 * math.log10(directivity)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Antenna pattern functions ─────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Each returns linear gain (directivity) for given angles.
|
||||||
|
# Convention: theta = polar angle from z-axis (0° = zenith),
|
||||||
|
# phi = azimuthal angle in x-y plane (0° = x-axis).
|
||||||
|
# Patterns are normalized so peak directivity matches the theoretical value.
|
||||||
|
|
||||||
|
|
||||||
|
def dipole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
|
||||||
|
"""Half-wave dipole radiation pattern.
|
||||||
|
|
||||||
|
Uses the exact far-field expression for a center-fed thin dipole:
|
||||||
|
E(θ) = cos(kL/2 · cos θ) - cos(kL/2) / sin θ
|
||||||
|
|
||||||
|
For a half-wave dipole (L = λ/2), this simplifies to:
|
||||||
|
E(θ) = cos(π/2 · cos θ) / sin θ
|
||||||
|
|
||||||
|
Peak directivity: 2.15 dBi (1.64 linear).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta_deg: Polar angle from antenna axis (degrees, 0=along wire)
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
length_m: Physical length in meters (default: half-wavelength)
|
||||||
|
"""
|
||||||
|
wavelength = C / frequency_hz
|
||||||
|
if length_m is None:
|
||||||
|
length_m = wavelength / 2.0
|
||||||
|
|
||||||
|
theta = math.radians(theta_deg)
|
||||||
|
sin_theta = math.sin(theta)
|
||||||
|
|
||||||
|
if abs(sin_theta) < 1e-10:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
k = 2.0 * math.pi / wavelength
|
||||||
|
half_kl = k * length_m / 2.0
|
||||||
|
cos_theta = math.cos(theta)
|
||||||
|
|
||||||
|
numerator = math.cos(half_kl * cos_theta) - math.cos(half_kl)
|
||||||
|
e_field = numerator / sin_theta
|
||||||
|
|
||||||
|
# Normalize to peak directivity of 1.64 (2.15 dBi) for half-wave
|
||||||
|
# The raw max of cos(π/2·cosθ)/sinθ is 1.0 at θ=90°
|
||||||
|
directivity = 1.64 * e_field * e_field
|
||||||
|
return directivity
|
||||||
|
|
||||||
|
|
||||||
|
def monopole_gain(theta_deg: float, frequency_hz: float, length_m: float | None = None) -> float:
|
||||||
|
"""Quarter-wave monopole over perfect ground plane.
|
||||||
|
|
||||||
|
Image theory: a monopole over ground has the same pattern as the upper
|
||||||
|
hemisphere of a dipole, but with doubled directivity (energy only radiates
|
||||||
|
into the upper hemisphere).
|
||||||
|
|
||||||
|
Peak directivity: 5.15 dBi (3.28 linear) = dipole + 3 dB ground gain.
|
||||||
|
Impedance at resonance: ~36 Ω (half of dipole's 73 Ω).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta_deg: Elevation angle (0=horizon, 90=zenith)
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
length_m: Physical length in meters (default: quarter-wavelength)
|
||||||
|
"""
|
||||||
|
wavelength = C / frequency_hz
|
||||||
|
if length_m is None:
|
||||||
|
length_m = wavelength / 4.0
|
||||||
|
|
||||||
|
# Monopole: no radiation below ground plane
|
||||||
|
if theta_deg > 90.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Map elevation to dipole theta (monopole elevation 0°=horizon → dipole 90°)
|
||||||
|
dipole_theta = 90.0 - theta_deg
|
||||||
|
|
||||||
|
# Use dipole pattern with doubled length (image theory)
|
||||||
|
gain = dipole_gain(dipole_theta, frequency_hz, length_m * 2.0)
|
||||||
|
# Double directivity for hemisphere-only radiation
|
||||||
|
return gain * 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def loop_gain(theta_deg: float, frequency_hz: float, circumference_m: float | None = None) -> float:
|
||||||
|
"""Small magnetic loop antenna pattern.
|
||||||
|
|
||||||
|
A small loop (circumference << λ) acts as a magnetic dipole with
|
||||||
|
a sin(θ) pattern (null along the loop axis, maximum in the plane
|
||||||
|
of the loop).
|
||||||
|
|
||||||
|
Peak directivity: 1.76 dBi (1.5 linear) — same as a short dipole.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta_deg: Polar angle from loop axis (0=through loop, 90=in loop plane)
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
circumference_m: Loop circumference in meters (default: λ/10)
|
||||||
|
"""
|
||||||
|
wavelength = C / frequency_hz
|
||||||
|
if circumference_m is None:
|
||||||
|
circumference_m = wavelength / 10.0
|
||||||
|
|
||||||
|
theta = math.radians(theta_deg)
|
||||||
|
sin_theta = math.sin(theta)
|
||||||
|
|
||||||
|
# Small loop: D(θ) = 1.5 * sin²(θ)
|
||||||
|
return 1.5 * sin_theta * sin_theta
|
||||||
|
|
||||||
|
|
||||||
|
def patch_gain(
|
||||||
|
theta_deg: float,
|
||||||
|
phi_deg: float,
|
||||||
|
frequency_hz: float,
|
||||||
|
width_m: float | None = None,
|
||||||
|
length_m: float | None = None,
|
||||||
|
er: float = 4.4,
|
||||||
|
) -> float:
|
||||||
|
"""Rectangular microstrip patch antenna (cavity model).
|
||||||
|
|
||||||
|
The patch radiates broadside (maximum at θ=0). The E-plane (φ=0) and
|
||||||
|
H-plane (φ=90°) patterns differ due to the rectangular aperture.
|
||||||
|
|
||||||
|
E-plane: cos(θ) envelope × sinc(kW/2 · sin θ)
|
||||||
|
H-plane: cos(kL_eff/2 · sin θ) × cos(θ)
|
||||||
|
|
||||||
|
Peak directivity: ~6-8 dBi depending on substrate.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta_deg: Polar angle from broadside (0=directly above patch)
|
||||||
|
phi_deg: Azimuthal angle (0=E-plane, 90=H-plane)
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
width_m: Patch width in meters (default: estimated from frequency + εr)
|
||||||
|
length_m: Patch length in meters (default: λ_eff/2)
|
||||||
|
er: Substrate relative permittivity (default 4.4 for FR-4)
|
||||||
|
"""
|
||||||
|
wavelength = C / frequency_hz
|
||||||
|
lambda_eff = wavelength / math.sqrt(er)
|
||||||
|
|
||||||
|
if length_m is None:
|
||||||
|
length_m = lambda_eff / 2.0
|
||||||
|
if width_m is None:
|
||||||
|
width_m = wavelength / 2.0 # Typical width ~ free-space λ/2
|
||||||
|
|
||||||
|
theta = math.radians(theta_deg)
|
||||||
|
phi = math.radians(phi_deg)
|
||||||
|
cos_theta = math.cos(theta)
|
||||||
|
sin_theta = math.sin(theta)
|
||||||
|
cos_phi = math.cos(phi)
|
||||||
|
sin_phi = math.sin(phi)
|
||||||
|
|
||||||
|
# No radiation behind ground plane
|
||||||
|
if theta_deg > 90.0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
k0 = 2.0 * math.pi / wavelength
|
||||||
|
|
||||||
|
# E-plane factor (along patch length)
|
||||||
|
kw_arg = k0 * width_m * sin_theta * cos_phi / 2.0
|
||||||
|
if abs(kw_arg) < 1e-10:
|
||||||
|
sinc_e = 1.0
|
||||||
|
else:
|
||||||
|
sinc_e = math.sin(kw_arg) / kw_arg
|
||||||
|
|
||||||
|
# H-plane factor (along patch width)
|
||||||
|
kl_arg = k0 * length_m * sin_theta * sin_phi / 2.0
|
||||||
|
cos_h = math.cos(kl_arg)
|
||||||
|
|
||||||
|
# Combine: directivity pattern
|
||||||
|
pattern = cos_theta * sinc_e * cos_h
|
||||||
|
gain = pattern * pattern
|
||||||
|
|
||||||
|
# Approximate peak directivity for a patch: D ≈ 4πWL/λ² * radiation efficiency
|
||||||
|
# Simplified to ~6.6 (8.2 dBi) for typical patch
|
||||||
|
peak_d = min(4.0 * math.pi * width_m * length_m / (wavelength * wavelength), 8.0)
|
||||||
|
peak_d = max(peak_d, 4.0) # Floor at ~6 dBi
|
||||||
|
|
||||||
|
return peak_d * gain
|
||||||
|
|
||||||
|
|
||||||
|
def efhw_gain(theta_deg: float, frequency_hz: float) -> float:
|
||||||
|
"""End-Fed Half-Wave (EFHW) antenna pattern.
|
||||||
|
|
||||||
|
An EFHW has fundamentally the same radiation pattern as a center-fed
|
||||||
|
half-wave dipole — the current distribution is the same sinusoidal
|
||||||
|
shape. The feed-point impedance is very high (~2500-5000 Ω) because
|
||||||
|
it's fed at the voltage maximum, but the far-field pattern is identical.
|
||||||
|
|
||||||
|
Peak directivity: 2.15 dBi (1.64 linear).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theta_deg: Polar angle from wire axis (0=along wire)
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
"""
|
||||||
|
return dipole_gain(theta_deg, frequency_hz, length_m=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Antenna type estimation ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_antenna_type(
|
||||||
|
impedance_real: float,
|
||||||
|
impedance_imag: float,
|
||||||
|
resonant_freq_hz: float,
|
||||||
|
bandwidth_hz: float = 0,
|
||||||
|
) -> str:
|
||||||
|
"""Estimate antenna type from measured impedance at resonance.
|
||||||
|
|
||||||
|
Uses feed-point impedance as the primary discriminator:
|
||||||
|
- ~73 Ω → half-wave dipole
|
||||||
|
- ~36 Ω → quarter-wave monopole (or λ/4 vertical)
|
||||||
|
- ~2500-5000 Ω → EFHW
|
||||||
|
- Low R with high inductive X → small loop
|
||||||
|
- ~50-300 Ω broadside → patch
|
||||||
|
|
||||||
|
This is a heuristic — many antennas don't fit neatly into categories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
impedance_real: Resistance at resonance (Ω)
|
||||||
|
impedance_imag: Reactance at resonance (Ω)
|
||||||
|
resonant_freq_hz: Resonant frequency in Hz
|
||||||
|
bandwidth_hz: 2:1 SWR bandwidth in Hz (0 if unknown)
|
||||||
|
"""
|
||||||
|
r = impedance_real
|
||||||
|
x = abs(impedance_imag)
|
||||||
|
|
||||||
|
if r > 1500:
|
||||||
|
return "efhw"
|
||||||
|
if r < 5 and x > 10:
|
||||||
|
return "loop"
|
||||||
|
if 25 <= r <= 42 and x < 20:
|
||||||
|
return "monopole"
|
||||||
|
if 45 <= r <= 100 and x < 30:
|
||||||
|
return "dipole"
|
||||||
|
if 100 < r <= 500 and x < 50:
|
||||||
|
return "patch"
|
||||||
|
|
||||||
|
# Fallback: use resonant impedance vs canonical values
|
||||||
|
if abs(r - 73) < abs(r - 36):
|
||||||
|
return "dipole"
|
||||||
|
return "monopole"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3D pattern generation ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def generate_3d_pattern(
|
||||||
|
antenna_type: str,
|
||||||
|
frequency_hz: float,
|
||||||
|
s11_analysis: dict | None = None,
|
||||||
|
theta_step: float = 2.0,
|
||||||
|
phi_step: float = 5.0,
|
||||||
|
length_m: float | None = None,
|
||||||
|
er: float = 4.4,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate a full 3D radiation pattern grid.
|
||||||
|
|
||||||
|
Returns a dict with theta/phi axes and a 2D gain grid suitable for
|
||||||
|
rendering as a gain-mapped sphere in Three.js.
|
||||||
|
|
||||||
|
This is the standardized output format shared between Phase 1 (analytical)
|
||||||
|
and Phase 2 (measured) patterns.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: One of 'dipole', 'monopole', 'efhw', 'loop', 'patch'
|
||||||
|
frequency_hz: Operating frequency in Hz
|
||||||
|
s11_analysis: Optional S11 analysis dict (from analyze_scan) for context
|
||||||
|
theta_step: Polar angle resolution in degrees (default 2°)
|
||||||
|
phi_step: Azimuthal angle resolution in degrees (default 5°)
|
||||||
|
length_m: Override antenna element length in meters
|
||||||
|
er: Substrate εr for patch antennas (default 4.4)
|
||||||
|
"""
|
||||||
|
thetas = _deg_range(0.0, 180.0, theta_step)
|
||||||
|
phis = _deg_range(0.0, 355.0, phi_step)
|
||||||
|
|
||||||
|
# Build gain grid: gain[i_theta][i_phi] in dBi
|
||||||
|
gain_grid: list[list[float]] = []
|
||||||
|
peak_linear = 0.0
|
||||||
|
|
||||||
|
for theta in thetas:
|
||||||
|
row: list[float] = []
|
||||||
|
for phi in phis:
|
||||||
|
if antenna_type == "dipole":
|
||||||
|
g = dipole_gain(theta, frequency_hz, length_m)
|
||||||
|
elif antenna_type == "monopole":
|
||||||
|
g = monopole_gain(theta, frequency_hz, length_m)
|
||||||
|
elif antenna_type == "efhw":
|
||||||
|
g = efhw_gain(theta, frequency_hz)
|
||||||
|
elif antenna_type == "loop":
|
||||||
|
g = loop_gain(theta, frequency_hz)
|
||||||
|
elif antenna_type == "patch":
|
||||||
|
g = patch_gain(theta, phi, frequency_hz, length_m=length_m, er=er)
|
||||||
|
else:
|
||||||
|
g = dipole_gain(theta, frequency_hz, length_m)
|
||||||
|
|
||||||
|
if g > peak_linear:
|
||||||
|
peak_linear = g
|
||||||
|
row.append(g)
|
||||||
|
gain_grid.append(row)
|
||||||
|
|
||||||
|
# Convert to dBi
|
||||||
|
gain_dbi: list[list[float]] = []
|
||||||
|
for row in gain_grid:
|
||||||
|
gain_dbi.append([_directivity_to_dbi(g) for g in row])
|
||||||
|
|
||||||
|
peak_dbi = _directivity_to_dbi(peak_linear)
|
||||||
|
|
||||||
|
# Compute E-plane and H-plane cuts
|
||||||
|
# E-plane: φ=0° (first column of each theta row)
|
||||||
|
e_plane = [{"theta_deg": thetas[i], "gain_dbi": gain_dbi[i][0]} for i in range(len(thetas))]
|
||||||
|
|
||||||
|
# H-plane: θ=90° (row at theta=90°, all phi values)
|
||||||
|
theta_90_idx = min(range(len(thetas)), key=lambda i: abs(thetas[i] - 90.0))
|
||||||
|
h_plane = [{"phi_deg": phis[j], "gain_dbi": gain_dbi[theta_90_idx][j]} for j in range(len(phis))]
|
||||||
|
|
||||||
|
wavelength = C / frequency_hz
|
||||||
|
|
||||||
|
result: dict = {
|
||||||
|
"antenna_type": antenna_type,
|
||||||
|
"frequency_hz": frequency_hz,
|
||||||
|
"wavelength_m": round(wavelength, 4),
|
||||||
|
"theta_deg": thetas,
|
||||||
|
"phi_deg": phis,
|
||||||
|
"gain_dbi": gain_dbi,
|
||||||
|
"peak_gain_dbi": round(peak_dbi, 2),
|
||||||
|
"e_plane": e_plane,
|
||||||
|
"h_plane": h_plane,
|
||||||
|
"grid_size": {"theta_points": len(thetas), "phi_points": len(phis), "total_points": len(thetas) * len(phis)},
|
||||||
|
"model": "analytical",
|
||||||
|
}
|
||||||
|
|
||||||
|
if s11_analysis:
|
||||||
|
result["s11_context"] = s11_analysis
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Multi-frequency pattern generation ────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def generate_multi_frequency_patterns(
|
||||||
|
antenna_type: str,
|
||||||
|
frequencies_hz: list[float],
|
||||||
|
s11_analysis: dict | None = None,
|
||||||
|
theta_step: float = 4.0,
|
||||||
|
phi_step: float = 10.0,
|
||||||
|
length_m: float | None = None,
|
||||||
|
er: float = 4.4,
|
||||||
|
) -> dict:
|
||||||
|
"""Generate patterns at multiple frequencies for animation.
|
||||||
|
|
||||||
|
Uses a coarser grid (4°×10° = 46×36 = 1656 points per frequency)
|
||||||
|
to keep total payload reasonable for WebSocket streaming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: Antenna type string
|
||||||
|
frequencies_hz: List of frequencies to compute patterns at
|
||||||
|
s11_analysis: Optional analysis context
|
||||||
|
theta_step: Coarser theta step for multi-freq (default 4°)
|
||||||
|
phi_step: Coarser phi step for multi-freq (default 10°)
|
||||||
|
length_m: Override antenna element length
|
||||||
|
er: Substrate εr for patch
|
||||||
|
"""
|
||||||
|
patterns = []
|
||||||
|
for freq in frequencies_hz:
|
||||||
|
p = generate_3d_pattern(
|
||||||
|
antenna_type,
|
||||||
|
freq,
|
||||||
|
s11_analysis=None,
|
||||||
|
theta_step=theta_step,
|
||||||
|
phi_step=phi_step,
|
||||||
|
length_m=length_m,
|
||||||
|
er=er,
|
||||||
|
)
|
||||||
|
# Slim down for multi-freq: drop plane cuts, keep grid
|
||||||
|
patterns.append(
|
||||||
|
{
|
||||||
|
"frequency_hz": freq,
|
||||||
|
"gain_dbi": p["gain_dbi"],
|
||||||
|
"peak_gain_dbi": p["peak_gain_dbi"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"antenna_type": antenna_type,
|
||||||
|
"count": len(patterns),
|
||||||
|
"theta_deg": _deg_range(0.0, 180.0, theta_step),
|
||||||
|
"phi_deg": _deg_range(0.0, 355.0, phi_step),
|
||||||
|
"patterns": patterns,
|
||||||
|
"model": "analytical",
|
||||||
|
}
|
||||||
@ -90,6 +90,10 @@ _TOOL_METHODS = [
|
|||||||
"analyze_lc_match",
|
"analyze_lc_match",
|
||||||
"analyze_s11_resonance",
|
"analyze_s11_resonance",
|
||||||
"analyze",
|
"analyze",
|
||||||
|
# tools/radiation.py — RadiationMixin
|
||||||
|
"radiation_pattern",
|
||||||
|
"radiation_pattern_from_data",
|
||||||
|
"radiation_pattern_multi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -112,10 +116,14 @@ def create_server() -> FastMCP:
|
|||||||
"'analyze_lc_series' and 'analyze_lc_shunt' (resonator parameters from S21), "
|
"'analyze_lc_series' and 'analyze_lc_shunt' (resonator parameters from S21), "
|
||||||
"'analyze_lc_match' (L-network matching solver, accepts direct R+jX or scans S11), "
|
"'analyze_lc_match' (L-network matching solver, accepts direct R+jX or scans S11), "
|
||||||
"'analyze_s11_resonance' (find up to 6 resonant frequencies).\n\n"
|
"'analyze_s11_resonance' (find up to 6 resonant frequencies).\n\n"
|
||||||
|
"Radiation pattern tools: 'radiation_pattern' (scan S11 → 3D pattern), "
|
||||||
|
"'radiation_pattern_from_data' (compute pattern from known impedance, no hardware), "
|
||||||
|
"'radiation_pattern_multi' (patterns at N frequencies for animation). "
|
||||||
|
"Supported antenna types: dipole, monopole, efhw, loop, patch, or 'auto' to estimate.\n\n"
|
||||||
"Prompts are available for guided workflows: calibrate, export_touchstone, "
|
"Prompts are available for guided workflows: calibrate, export_touchstone, "
|
||||||
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
|
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
|
||||||
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
|
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
|
||||||
"measure_lc_shunt, and impedance_match."
|
"measure_lc_shunt, impedance_match, and visualize_radiation_pattern."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
vna = NanoVNA()
|
vna = NanoVNA()
|
||||||
@ -131,6 +139,8 @@ def create_server() -> FastMCP:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
import os
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
@ -140,6 +150,19 @@ def main() -> None:
|
|||||||
|
|
||||||
print(f"mcnanovna v{package_version} — NanoVNA-H MCP server")
|
print(f"mcnanovna v{package_version} — NanoVNA-H MCP server")
|
||||||
|
|
||||||
|
web_port = os.environ.get("MCNANOVNA_WEB_PORT")
|
||||||
|
if web_port:
|
||||||
|
try:
|
||||||
|
port = int(web_port)
|
||||||
|
from mcnanovna.webui import start_web_server
|
||||||
|
|
||||||
|
start_web_server(port)
|
||||||
|
print(f"Web UI available at http://localhost:{port}")
|
||||||
|
except ImportError:
|
||||||
|
print("Web UI requires optional dependencies: pip install mcnanovna[webui]")
|
||||||
|
except ValueError:
|
||||||
|
print(f"Invalid MCNANOVNA_WEB_PORT: {web_port}")
|
||||||
|
|
||||||
server = create_server()
|
server = create_server()
|
||||||
server.run()
|
server.run()
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from .device import DeviceMixin
|
|||||||
from .diagnostics import DiagnosticsMixin
|
from .diagnostics import DiagnosticsMixin
|
||||||
from .display import DisplayMixin
|
from .display import DisplayMixin
|
||||||
from .measurement import MeasurementMixin
|
from .measurement import MeasurementMixin
|
||||||
|
from .radiation import RadiationMixin
|
||||||
|
|
||||||
|
|
||||||
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
|
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
|
||||||
@ -30,4 +31,5 @@ __all__ = [
|
|||||||
"DiagnosticsMixin",
|
"DiagnosticsMixin",
|
||||||
"DisplayMixin",
|
"DisplayMixin",
|
||||||
"MeasurementMixin",
|
"MeasurementMixin",
|
||||||
|
"RadiationMixin",
|
||||||
]
|
]
|
||||||
|
|||||||
204
src/mcnanovna/tools/radiation.py
Normal file
204
src/mcnanovna/tools/radiation.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"""RadiationMixin — 3D antenna radiation pattern tools."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastmcp import Context
|
||||||
|
|
||||||
|
|
||||||
|
class RadiationMixin:
|
||||||
|
"""Radiation pattern tools: radiation_pattern, radiation_pattern_from_data, radiation_pattern_multi."""
|
||||||
|
|
||||||
|
async def radiation_pattern(
|
||||||
|
self,
|
||||||
|
antenna_type: str = "dipole",
|
||||||
|
start_hz: int | None = None,
|
||||||
|
stop_hz: int | None = None,
|
||||||
|
points: int = 101,
|
||||||
|
theta_step: float = 2.0,
|
||||||
|
phi_step: float = 5.0,
|
||||||
|
er: float = 4.4,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Scan S11, find resonance, and compute a 3D radiation pattern.
|
||||||
|
|
||||||
|
Performs an S11 scan across the specified range, identifies the resonant
|
||||||
|
frequency and impedance, optionally estimates the antenna type, then
|
||||||
|
generates an analytical 3D radiation pattern grid.
|
||||||
|
|
||||||
|
Returns {theta_deg, phi_deg, gain_dbi} suitable for 3D visualization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto' to estimate from impedance
|
||||||
|
start_hz: Start frequency in Hz (default: 2m band 144 MHz)
|
||||||
|
stop_hz: Stop frequency in Hz (default: 2m band 148 MHz)
|
||||||
|
points: Number of S11 scan points (default 101)
|
||||||
|
theta_step: Polar angle resolution in degrees (default 2°)
|
||||||
|
phi_step: Azimuthal angle resolution in degrees (default 5°)
|
||||||
|
er: Substrate εr for patch antennas (default 4.4 for FR-4)
|
||||||
|
"""
|
||||||
|
from mcnanovna.calculations import analyze_scan, find_resonance
|
||||||
|
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
|
||||||
|
from mcnanovna.tools import _progress
|
||||||
|
|
||||||
|
if start_hz is None:
|
||||||
|
start_hz = 144_000_000
|
||||||
|
if stop_hz is None:
|
||||||
|
stop_hz = 148_000_000
|
||||||
|
|
||||||
|
await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
|
||||||
|
await asyncio.to_thread(self._ensure_connected)
|
||||||
|
|
||||||
|
await _progress(ctx, 2, 5, f"Scanning S11 ({points} points)...")
|
||||||
|
scan_result = await self.scan(start_hz, stop_hz, points, s11=True, s21=False)
|
||||||
|
if "error" in scan_result:
|
||||||
|
return scan_result
|
||||||
|
|
||||||
|
await _progress(ctx, 3, 5, "Analyzing S11 data...")
|
||||||
|
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||||
|
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||||
|
s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
|
||||||
|
|
||||||
|
analysis = analyze_scan(scan_result["data"])
|
||||||
|
resonance = find_resonance(s11_complex, freqs)
|
||||||
|
|
||||||
|
# Auto-detect antenna type from impedance
|
||||||
|
if antenna_type == "auto":
|
||||||
|
res_freq = resonance.get("frequency_hz", freqs[len(freqs) // 2])
|
||||||
|
z_real = resonance.get("impedance_real", 50.0)
|
||||||
|
z_imag = resonance.get("impedance_imag", 0.0)
|
||||||
|
bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
|
||||||
|
antenna_type = estimate_antenna_type(z_real, z_imag, res_freq, bw)
|
||||||
|
|
||||||
|
res_freq = resonance.get("frequency_hz", (start_hz + stop_hz) // 2)
|
||||||
|
|
||||||
|
await _progress(ctx, 4, 5, f"Computing {antenna_type} radiation pattern at {res_freq} Hz...")
|
||||||
|
pattern = generate_3d_pattern(
|
||||||
|
antenna_type=antenna_type,
|
||||||
|
frequency_hz=res_freq,
|
||||||
|
s11_analysis=analysis.get("s11_analysis"),
|
||||||
|
theta_step=theta_step,
|
||||||
|
phi_step=phi_step,
|
||||||
|
er=er,
|
||||||
|
)
|
||||||
|
|
||||||
|
pattern["scan_info"] = {
|
||||||
|
"start_hz": start_hz,
|
||||||
|
"stop_hz": stop_hz,
|
||||||
|
"points": scan_result["points"],
|
||||||
|
}
|
||||||
|
pattern["resonance"] = resonance
|
||||||
|
|
||||||
|
await _progress(ctx, 5, 5, "Radiation pattern complete")
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
async def radiation_pattern_from_data(
|
||||||
|
self,
|
||||||
|
antenna_type: str = "dipole",
|
||||||
|
frequency_hz: float = 145_000_000,
|
||||||
|
impedance_real: float = 73.0,
|
||||||
|
impedance_imag: float = 0.0,
|
||||||
|
theta_step: float = 2.0,
|
||||||
|
phi_step: float = 5.0,
|
||||||
|
length_m: float | None = None,
|
||||||
|
er: float = 4.4,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Compute a 3D radiation pattern from provided impedance (no hardware).
|
||||||
|
|
||||||
|
Generates an analytical radiation pattern using the specified antenna
|
||||||
|
type and frequency. No VNA connection required — useful for what-if
|
||||||
|
analysis or when impedance is already known.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
|
||||||
|
frequency_hz: Operating frequency in Hz (default 145 MHz)
|
||||||
|
impedance_real: Known resistance at resonance in ohms (default 73)
|
||||||
|
impedance_imag: Known reactance at resonance in ohms (default 0)
|
||||||
|
theta_step: Polar angle resolution in degrees (default 2°)
|
||||||
|
phi_step: Azimuthal angle resolution in degrees (default 5°)
|
||||||
|
length_m: Override antenna element length in meters (default: calculated from frequency)
|
||||||
|
er: Substrate εr for patch antennas (default 4.4)
|
||||||
|
"""
|
||||||
|
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
|
||||||
|
from mcnanovna.tools import _progress
|
||||||
|
|
||||||
|
if antenna_type == "auto":
|
||||||
|
antenna_type = estimate_antenna_type(impedance_real, impedance_imag, frequency_hz)
|
||||||
|
|
||||||
|
await _progress(ctx, 1, 2, f"Computing {antenna_type} pattern at {frequency_hz} Hz...")
|
||||||
|
|
||||||
|
s11_context = {
|
||||||
|
"resonance": {
|
||||||
|
"frequency_hz": frequency_hz,
|
||||||
|
"impedance_real": impedance_real,
|
||||||
|
"impedance_imag": impedance_imag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = generate_3d_pattern(
|
||||||
|
antenna_type=antenna_type,
|
||||||
|
frequency_hz=frequency_hz,
|
||||||
|
s11_analysis=s11_context,
|
||||||
|
theta_step=theta_step,
|
||||||
|
phi_step=phi_step,
|
||||||
|
length_m=length_m,
|
||||||
|
er=er,
|
||||||
|
)
|
||||||
|
|
||||||
|
await _progress(ctx, 2, 2, "Pattern generation complete")
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
async def radiation_pattern_multi(
|
||||||
|
self,
|
||||||
|
antenna_type: str = "dipole",
|
||||||
|
start_hz: int = 144_000_000,
|
||||||
|
stop_hz: int = 148_000_000,
|
||||||
|
num_frequencies: int = 5,
|
||||||
|
theta_step: float = 4.0,
|
||||||
|
phi_step: float = 10.0,
|
||||||
|
length_m: float | None = None,
|
||||||
|
er: float = 4.4,
|
||||||
|
ctx: Context | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Compute radiation patterns at multiple frequencies across a band.
|
||||||
|
|
||||||
|
Generates a series of 3D patterns at evenly-spaced frequencies for
|
||||||
|
animation or comparison. Uses a coarser grid to keep payload size
|
||||||
|
manageable for WebSocket streaming.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch'
|
||||||
|
start_hz: Band start frequency in Hz
|
||||||
|
stop_hz: Band stop frequency in Hz
|
||||||
|
num_frequencies: Number of frequency steps (default 5)
|
||||||
|
theta_step: Polar angle resolution in degrees (default 4°, coarser for multi)
|
||||||
|
phi_step: Azimuthal angle resolution in degrees (default 10°, coarser for multi)
|
||||||
|
length_m: Override antenna element length in meters
|
||||||
|
er: Substrate εr for patch antennas (default 4.4)
|
||||||
|
"""
|
||||||
|
from mcnanovna.radiation import generate_multi_frequency_patterns
|
||||||
|
from mcnanovna.tools import _progress
|
||||||
|
|
||||||
|
if num_frequencies < 2:
|
||||||
|
num_frequencies = 2
|
||||||
|
if num_frequencies > 20:
|
||||||
|
num_frequencies = 20
|
||||||
|
|
||||||
|
step = (stop_hz - start_hz) / (num_frequencies - 1)
|
||||||
|
frequencies = [start_hz + int(i * step) for i in range(num_frequencies)]
|
||||||
|
|
||||||
|
await _progress(ctx, 1, 2, f"Computing {num_frequencies} patterns from {start_hz} to {stop_hz} Hz...")
|
||||||
|
|
||||||
|
result = generate_multi_frequency_patterns(
|
||||||
|
antenna_type=antenna_type,
|
||||||
|
frequencies_hz=frequencies,
|
||||||
|
theta_step=theta_step,
|
||||||
|
phi_step=phi_step,
|
||||||
|
length_m=length_m,
|
||||||
|
er=er,
|
||||||
|
)
|
||||||
|
|
||||||
|
await _progress(ctx, 2, 2, f"Generated {num_frequencies} radiation patterns")
|
||||||
|
return result
|
||||||
32
src/mcnanovna/webui/__init__.py
Normal file
32
src/mcnanovna/webui/__init__.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Optional web UI for 3D radiation pattern visualization.
|
||||||
|
|
||||||
|
Requires optional dependencies: pip install mcnanovna[webui]
|
||||||
|
Activated by setting MCNANOVNA_WEB_PORT=8080 environment variable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
def start_web_server(port: int = 8080) -> None:
|
||||||
|
"""Start the FastAPI web server in a background thread.
|
||||||
|
|
||||||
|
The web server shares the same process as the MCP server but listens
|
||||||
|
on a separate TCP port. It serves the built frontend assets and
|
||||||
|
provides REST + WebSocket endpoints for pattern computation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
port: TCP port to listen on (default 8080)
|
||||||
|
"""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from mcnanovna.webui.api import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run, daemon=True, name="mcnanovna-webui")
|
||||||
|
thread.start()
|
||||||
218
src/mcnanovna/webui/api.py
Normal file
218
src/mcnanovna/webui/api.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
"""FastAPI REST + WebSocket endpoints for the radiation pattern web UI.
|
||||||
|
|
||||||
|
Serves the built frontend assets and provides endpoints for pattern
|
||||||
|
computation, VNA scanning, and real-time WebSocket updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
|
# Shared lock prevents concurrent VNA scans from web + MCP
|
||||||
|
_scan_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Connected WebSocket clients for pattern broadcast
|
||||||
|
_ws_clients: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeRequest(BaseModel):
|
||||||
|
antenna_type: str = "dipole"
|
||||||
|
frequency_hz: float = 145_000_000
|
||||||
|
impedance_real: float = 73.0
|
||||||
|
impedance_imag: float = 0.0
|
||||||
|
theta_step: float = 2.0
|
||||||
|
phi_step: float = 5.0
|
||||||
|
length_m: float | None = None
|
||||||
|
er: float = 4.4
|
||||||
|
|
||||||
|
|
||||||
|
class ScanRequest(BaseModel):
|
||||||
|
antenna_type: str = "auto"
|
||||||
|
start_hz: int = 144_000_000
|
||||||
|
stop_hz: int = 148_000_000
|
||||||
|
points: int = 101
|
||||||
|
theta_step: float = 2.0
|
||||||
|
phi_step: float = 5.0
|
||||||
|
er: float = 4.4
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(title="mcnanovna Web UI", docs_url=None, redoc_url=None)
|
||||||
|
|
||||||
|
# ── API routes ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def api_status():
|
||||||
|
"""VNA connection status."""
|
||||||
|
return {"status": "ok", "web_ui": True, "clients": len(_ws_clients)}
|
||||||
|
|
||||||
|
@app.get("/api/bands")
|
||||||
|
async def api_bands():
|
||||||
|
"""Ham band presets for the frequency selector."""
|
||||||
|
from mcnanovna.prompts import HAM_BANDS
|
||||||
|
|
||||||
|
return {name: {"start_hz": start, "stop_hz": stop} for name, (start, stop) in HAM_BANDS.items()}
|
||||||
|
|
||||||
|
@app.post("/api/pattern/compute")
|
||||||
|
async def api_pattern_compute(req: ComputeRequest):
|
||||||
|
"""Compute a radiation pattern from provided impedance (no VNA needed)."""
|
||||||
|
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
|
||||||
|
|
||||||
|
antenna_type = req.antenna_type
|
||||||
|
if antenna_type == "auto":
|
||||||
|
antenna_type = estimate_antenna_type(req.impedance_real, req.impedance_imag, req.frequency_hz)
|
||||||
|
|
||||||
|
pattern = generate_3d_pattern(
|
||||||
|
antenna_type=antenna_type,
|
||||||
|
frequency_hz=req.frequency_hz,
|
||||||
|
s11_analysis={
|
||||||
|
"resonance": {
|
||||||
|
"frequency_hz": req.frequency_hz,
|
||||||
|
"impedance_real": req.impedance_real,
|
||||||
|
"impedance_imag": req.impedance_imag,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
theta_step=req.theta_step,
|
||||||
|
phi_step=req.phi_step,
|
||||||
|
length_m=req.length_m,
|
||||||
|
er=req.er,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Broadcast to WebSocket clients
|
||||||
|
await _broadcast_pattern(pattern)
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
@app.post("/api/pattern")
|
||||||
|
async def api_pattern_scan(req: ScanRequest):
|
||||||
|
"""Scan S11 on the VNA and compute a radiation pattern."""
|
||||||
|
from mcnanovna.calculations import analyze_scan, find_resonance
|
||||||
|
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
|
||||||
|
|
||||||
|
async with _scan_lock:
|
||||||
|
# Lazy import — NanoVNA singleton not available until server starts
|
||||||
|
vna = _get_vna()
|
||||||
|
if vna is None:
|
||||||
|
return {"error": "VNA not available — MCP server must be running"}
|
||||||
|
|
||||||
|
scan_result = await vna.scan(req.start_hz, req.stop_hz, req.points, s11=True, s21=False)
|
||||||
|
|
||||||
|
if "error" in scan_result:
|
||||||
|
return scan_result
|
||||||
|
|
||||||
|
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||||
|
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||||
|
s11_complex = [complex(s["real"], s["imag"]) for s in s11_data]
|
||||||
|
|
||||||
|
analysis = analyze_scan(scan_result["data"])
|
||||||
|
resonance = find_resonance(s11_complex, freqs)
|
||||||
|
|
||||||
|
antenna_type = req.antenna_type
|
||||||
|
if antenna_type == "auto":
|
||||||
|
z_real = resonance.get("impedance_real", 50.0)
|
||||||
|
z_imag = resonance.get("impedance_imag", 0.0)
|
||||||
|
bw = analysis.get("s11_analysis", {}).get("bandwidth_2_1", {}).get("bandwidth_hz", 0)
|
||||||
|
antenna_type = estimate_antenna_type(z_real, z_imag, resonance.get("frequency_hz", 0), bw)
|
||||||
|
|
||||||
|
res_freq = resonance.get("frequency_hz", (req.start_hz + req.stop_hz) // 2)
|
||||||
|
|
||||||
|
pattern = generate_3d_pattern(
|
||||||
|
antenna_type=antenna_type,
|
||||||
|
frequency_hz=res_freq,
|
||||||
|
s11_analysis=analysis.get("s11_analysis"),
|
||||||
|
theta_step=req.theta_step,
|
||||||
|
phi_step=req.phi_step,
|
||||||
|
er=req.er,
|
||||||
|
)
|
||||||
|
pattern["resonance"] = resonance
|
||||||
|
pattern["scan_info"] = {
|
||||||
|
"start_hz": req.start_hz,
|
||||||
|
"stop_hz": req.stop_hz,
|
||||||
|
"points": scan_result["points"],
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcast_pattern(pattern)
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
# ── WebSocket ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.websocket("/ws/pattern")
|
||||||
|
async def ws_pattern(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
_ws_clients.add(websocket)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Keep connection alive; client can also send requests
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
msg = json.loads(data)
|
||||||
|
if msg.get("type") == "compute":
|
||||||
|
req = ComputeRequest(**msg.get("params", {}))
|
||||||
|
result = await api_pattern_compute(req)
|
||||||
|
await websocket.send_json({"type": "pattern", "data": result})
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
await websocket.send_json({"type": "error", "message": "Invalid request"})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_ws_clients.discard(websocket)
|
||||||
|
|
||||||
|
# ── Static files (built frontend) ─────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def serve_index():
|
||||||
|
index_path = STATIC_DIR / "index.html"
|
||||||
|
if index_path.exists():
|
||||||
|
return FileResponse(index_path)
|
||||||
|
return {"error": "Frontend not built. Run: cd frontend && npm run build"}
|
||||||
|
|
||||||
|
if STATIC_DIR.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_vna():
|
||||||
|
"""Try to get the shared NanoVNA instance from the MCP server.
|
||||||
|
|
||||||
|
Returns None if the MCP server hasn't started yet or if the module
|
||||||
|
structure doesn't support it. The web UI can still compute patterns
|
||||||
|
without hardware using the /api/pattern/compute endpoint.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# The NanoVNA is instantiated in create_server() — we create a
|
||||||
|
# separate lightweight instance for the web UI. It shares the same
|
||||||
|
# USB device (auto-discovery handles this).
|
||||||
|
from mcnanovna.nanovna import NanoVNA
|
||||||
|
|
||||||
|
if not hasattr(_get_vna, "_instance"):
|
||||||
|
_get_vna._instance = NanoVNA()
|
||||||
|
return _get_vna._instance
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _broadcast_pattern(pattern: dict) -> None:
|
||||||
|
"""Push a pattern update to all connected WebSocket clients."""
|
||||||
|
if not _ws_clients:
|
||||||
|
return
|
||||||
|
message = json.dumps({"type": "pattern", "data": pattern})
|
||||||
|
disconnected = set()
|
||||||
|
for ws in _ws_clients:
|
||||||
|
try:
|
||||||
|
await ws.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(ws)
|
||||||
|
_ws_clients.difference_update(disconnected)
|
||||||
1
src/mcnanovna/webui/static/assets/index-DeJaSUhK.css
Normal file
1
src/mcnanovna/webui/static/assets/index-DeJaSUhK.css
Normal file
File diff suppressed because one or more lines are too long
4022
src/mcnanovna/webui/static/assets/index-khVaWAJ2.js
Normal file
4022
src/mcnanovna/webui/static/assets/index-khVaWAJ2.js
Normal file
File diff suppressed because one or more lines are too long
18
src/mcnanovna/webui/static/index.html
Normal file
18
src/mcnanovna/webui/static/index.html
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>mcnanovna -- Radiation Pattern</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<script type="module" crossorigin src="/assets/index-khVaWAJ2.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DeJaSUhK.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="controls"></div>
|
||||||
|
<div id="scene-container"></div>
|
||||||
|
<canvas id="smith-chart" width="200" height="200"></canvas>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
213
uv.lock
generated
213
uv.lock
generated
@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-doc"
|
||||||
|
version = "0.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@ -416,6 +425,21 @@ lua = [
|
|||||||
{ name = "lupa" },
|
{ name = "lupa" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.128.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "2.14.4"
|
version = "2.14.4"
|
||||||
@ -467,6 +491,42 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx"
|
||||||
version = "0.28.1"
|
version = "0.28.1"
|
||||||
@ -703,7 +763,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcnanovna"
|
name = "mcnanovna"
|
||||||
version = "2026.1.30"
|
version = "2026.1.31"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
@ -711,12 +771,21 @@ dependencies = [
|
|||||||
{ name = "pyserial" },
|
{ name = "pyserial" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
webui = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "fastapi", marker = "extra == 'webui'", specifier = ">=0.115.0" },
|
||||||
{ name = "fastmcp", specifier = ">=2.14.0" },
|
{ name = "fastmcp", specifier = ">=2.14.0" },
|
||||||
{ name = "pillow", specifier = ">=11.0.0" },
|
{ name = "pillow", specifier = ">=11.0.0" },
|
||||||
{ name = "pyserial", specifier = ">=3.5" },
|
{ name = "pyserial", specifier = ">=3.5" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'webui'", specifier = ">=0.34.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["webui"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
@ -1550,15 +1619,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.52.1"
|
version = "0.50.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1619,6 +1688,142 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "16.0"
|
version = "16.0"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user