Compare commits
8 Commits
48e91a755c
...
21f4f0976e
| Author | SHA1 | Date | |
|---|---|---|---|
| 21f4f0976e | |||
| 748bfb8216 | |||
| 0b3e4bdf64 | |||
| a5ab129cc3 | |||
| c07284a7d6 | |||
| 430caf9e62 | |||
| 646c92324d | |||
| e0fe09f3b8 |
108
README.md
Normal file
108
README.md
Normal file
@ -0,0 +1,108 @@
|
||||
# mcnanovna
|
||||
|
||||
MCP server for controlling NanoVNA-H vector network analyzers over USB serial.
|
||||
|
||||
Provides 78 tools for frequency sweeps, S-parameter measurements, calibration, LCD capture, RF analysis, 3D antenna radiation pattern visualization, and pattern import from external files.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From PyPI (when published)
|
||||
uvx mcnanovna
|
||||
|
||||
# From source
|
||||
uv run mcnanovna
|
||||
|
||||
# With optional web UI for 3D pattern visualization
|
||||
uv sync --extra webui
|
||||
MCNANOVNA_WEB_PORT=8080 uv run mcnanovna
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
Just plug in your NanoVNA-H and start the server. The device auto-connects on first tool call.
|
||||
|
||||
```bash
|
||||
# Add to Claude Code
|
||||
claude mcp add mcnanovna -- uvx mcnanovna
|
||||
```
|
||||
|
||||
Then ask Claude to scan your antenna, analyze a filter, or measure a crystal.
|
||||
|
||||
## Tools
|
||||
|
||||
### Measurement
|
||||
`info`, `sweep`, `scan`, `data`, `frequencies`, `marker`, `cal`, `save`, `recall`, `pause`, `resume`
|
||||
|
||||
### Configuration
|
||||
`power`, `bandwidth`, `edelay`, `s21offset`, `vbat`, `capture`, `measure`, `config`, `saveconfig`, `clearconfig`, `color`, `freq`, `tcxo`, `vbat_offset`, `threshold`
|
||||
|
||||
### Display
|
||||
`trace`, `transform`, `smooth`, `touchcal`, `touchtest`, `refresh`, `touch`, `release`
|
||||
|
||||
### Device
|
||||
`reset`, `version`, `detect`, `disconnect`, `raw_command`, `cw`, `sd_list`, `sd_read`, `sd_delete`, `time`
|
||||
|
||||
### Diagnostics
|
||||
`i2c`, `si`, `lcd`, `threads`, `stat`, `sample`, `test`, `gain`, `dump`, `port`, `offset`, `dac`, `usart_cfg`, `usart`, `band`
|
||||
|
||||
### Analysis
|
||||
`analyze`, `export_touchstone`, `export_csv`, `analyze_filter`, `analyze_xtal`, `analyze_tdr`, `analyze_component`, `analyze_lc_series`, `analyze_lc_shunt`, `analyze_lc_match`, `analyze_s11_resonance`
|
||||
|
||||
### Radiation Patterns
|
||||
`radiation_pattern`, `radiation_pattern_from_data`, `radiation_pattern_multi`
|
||||
|
||||
### Pattern Import
|
||||
`import_pattern_csv`, `import_pattern_emcar`, `import_pattern_nec2`, `import_pattern_s1p`, `list_pattern_formats`
|
||||
|
||||
## Prompts
|
||||
|
||||
| Prompt | Description |
|
||||
|--------|-------------|
|
||||
| `calibrate` | SOLT calibration walkthrough |
|
||||
| `export_touchstone` | S-parameter export to .s1p/.s2p |
|
||||
| `analyze_antenna` | SWR, impedance, bandwidth analysis |
|
||||
| `measure_cable` | TDR and cable characterization |
|
||||
| `analyze_crystal` | Crystal motional parameter extraction |
|
||||
| `analyze_filter_response` | Filter type classification and cutoffs |
|
||||
| `measure_tdr` | Time domain reflectometry |
|
||||
| `compare_sweeps` | Before/after comparison |
|
||||
| `measure_component` | Unknown L/C/R identification |
|
||||
| `measure_lc_series` | Series LC resonator measurement |
|
||||
| `measure_lc_shunt` | Shunt LC resonator measurement |
|
||||
| `impedance_match` | L-network matching design |
|
||||
| `visualize_radiation_pattern` | 3D pattern from S11 scan |
|
||||
| `import_pattern` | Load patterns from CSV/EMCAR/NEC2/S1P |
|
||||
| `measure_antenna_range` | Automated 3D pattern with positioner |
|
||||
|
||||
## Web UI
|
||||
|
||||
Enable the optional 3D radiation pattern viewer:
|
||||
|
||||
```bash
|
||||
uv sync --extra webui
|
||||
MCNANOVNA_WEB_PORT=8080 uv run mcnanovna
|
||||
```
|
||||
|
||||
Open http://localhost:8080 for an interactive Three.js visualization of antenna patterns.
|
||||
|
||||
## Antenna Positioner Integration
|
||||
|
||||
For automated 3D pattern measurement, use together with [mcpositioner](../mcpositioner/):
|
||||
|
||||
```bash
|
||||
claude mcp add mcpositioner -- uvx mcpositioner
|
||||
claude mcp add mcnanovna -- uvx mcnanovna
|
||||
```
|
||||
|
||||
The `measure_antenna_range` prompt guides through the cross-server workflow.
|
||||
|
||||
## Supported Hardware
|
||||
|
||||
- NanoVNA-H (original)
|
||||
- NanoVNA-H4
|
||||
- Other variants using the same USB serial protocol (VID 0x0483, PID 0x5740)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
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"
|
||||
}
|
||||
}
|
||||
117
frontend/src/api.ts
Normal file
117
frontend/src/api.ts
Normal file
@ -0,0 +1,117 @@
|
||||
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 async function uploadPattern(
|
||||
file: File,
|
||||
options?: { frequency_hz?: number; polarization?: string; antenna_type?: string; reference_dbi?: number },
|
||||
): Promise<PatternData> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (options?.frequency_hz) form.append('frequency_hz', String(options.frequency_hz));
|
||||
if (options?.polarization) form.append('polarization', options.polarization);
|
||||
if (options?.antenna_type) form.append('antenna_type', options.antenna_type);
|
||||
if (options?.reference_dbi != null) form.append('reference_dbi', String(options.reference_dbi));
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/api/pattern/import`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
throw new Error(`Import failed (${resp.status}): ${body}`);
|
||||
}
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
254
frontend/src/controls.ts
Normal file
254
frontend/src/controls.ts
Normal file
@ -0,0 +1,254 @@
|
||||
import type { DisplayMode, AppState } from './types';
|
||||
import { iconRadio, iconActivity, iconCrosshair, iconUpload, iconWifi, iconWifiOff } from './icons';
|
||||
|
||||
export interface ControlCallbacks {
|
||||
onCompute: () => void;
|
||||
onScan: () => void;
|
||||
onFileImport: (file: File) => 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);
|
||||
|
||||
// Hidden file input for pattern import
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.csv,.dat,.out,.nec,.s1p';
|
||||
fileInput.style.display = 'none';
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files?.[0];
|
||||
if (file) {
|
||||
callbacks.onFileImport(file);
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
container.appendChild(fileInput);
|
||||
|
||||
const btnImport = document.createElement('button');
|
||||
btnImport.className = 'ctrl-btn ctrl-btn-outline';
|
||||
btnImport.innerHTML = `${iconUpload} Load File`;
|
||||
btnImport.addEventListener('click', () => fileInput.click());
|
||||
container.appendChild(btnImport);
|
||||
|
||||
// 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;
|
||||
btnImport.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>`;
|
||||
}
|
||||
25
frontend/src/icons.ts
Normal file
25
frontend/src/icons.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// 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>`;
|
||||
|
||||
/** Upload icon (Lucide) */
|
||||
export const iconUpload = `<svg ${ATTRS}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`;
|
||||
168
frontend/src/main.ts
Normal file
168
frontend/src/main.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { createScene, type SceneContext } from './scene';
|
||||
import { updatePattern } from './pattern';
|
||||
import { createControls } from './controls';
|
||||
import { drawSmithChart } from './smith';
|
||||
import { fetchPattern, scanPattern, uploadPattern, 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileImport(file: File) {
|
||||
state.loading = true;
|
||||
controlsUi.updateLoading(true);
|
||||
try {
|
||||
const data = await uploadPattern(file, {
|
||||
frequency_hz: state.frequencyMhz * 1e6,
|
||||
antenna_type: state.antennaType,
|
||||
});
|
||||
handlePatternData(data);
|
||||
} catch (err) {
|
||||
console.error('Import 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,
|
||||
onFileImport: handleFileImport,
|
||||
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();
|
||||
}
|
||||
407
frontend/src/style.css
Normal file
407
frontend/src/style.css
Normal file
@ -0,0 +1,407 @@
|
||||
/* === 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);
|
||||
}
|
||||
|
||||
.ctrl-btn-outline {
|
||||
background: transparent;
|
||||
color: var(--slate-400);
|
||||
border-color: var(--slate-700);
|
||||
border-style: dashed;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ctrl-btn-outline:hover:not(:disabled) {
|
||||
border-color: var(--slate-500);
|
||||
border-style: solid;
|
||||
color: var(--slate-200);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
56
frontend/src/types.ts
Normal file
56
frontend/src/types.ts
Normal file
@ -0,0 +1,56 @@
|
||||
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;
|
||||
raw_cut?: { angles_deg: number[]; gain_db: number[]; plane: string };
|
||||
import_info?: { format: string; filename?: string; points: number };
|
||||
}
|
||||
|
||||
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]
|
||||
name = "mcnanovna"
|
||||
version = "2026.01.30"
|
||||
version = "2026.02.01"
|
||||
description = "MCP server for NanoVNA-H vector network analyzers"
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
requires-python = ">=3.11"
|
||||
@ -10,6 +10,12 @@ dependencies = [
|
||||
"Pillow>=11.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
webui = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.34.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcnanovna = "mcnanovna.server:main"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
700
src/mcnanovna/pattern_import.py
Normal file
700
src/mcnanovna/pattern_import.py
Normal file
@ -0,0 +1,700 @@
|
||||
"""Import measured antenna patterns from external files.
|
||||
|
||||
Parses CSV, EMCAR vna.dat, NEC2 radiation output, and Touchstone S1P formats
|
||||
into the standardized {theta_deg, phi_deg, gain_dbi} dict that the 3D viewer
|
||||
and MCP tools already consume.
|
||||
|
||||
All functions are pure Python (math + list comprehensions). No external dependencies.
|
||||
Content is passed as strings, not file paths — MCP tools receive content from the LLM,
|
||||
and the web UI endpoint handles multipart upload separately.
|
||||
|
||||
Inspired by the EMCAR antenna range (https://emcar.sourceforge.net/) which uses
|
||||
LinuxCNC + HP8754A VNA for IEEE-149 stop-and-measure pattern recording.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
|
||||
from mcnanovna.radiation import C, _deg_range
|
||||
|
||||
|
||||
# ── Shared utilities ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_pattern_dict(
|
||||
thetas: list[float],
|
||||
phis: list[float],
|
||||
gain_dbi: list[list[float]],
|
||||
model: str,
|
||||
frequency_hz: float | None,
|
||||
antenna_type: str,
|
||||
metadata: dict | None = None,
|
||||
) -> dict:
|
||||
"""Assemble a standardized pattern dict matching generate_3d_pattern() output."""
|
||||
peak = -999.0
|
||||
for row in gain_dbi:
|
||||
for g in row:
|
||||
if g > peak:
|
||||
peak = g
|
||||
|
||||
# E-plane: phi=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: theta=90 (row closest to 90 deg, 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))]
|
||||
|
||||
result: dict = {
|
||||
"antenna_type": antenna_type,
|
||||
"frequency_hz": frequency_hz,
|
||||
"theta_deg": thetas,
|
||||
"phi_deg": phis,
|
||||
"gain_dbi": gain_dbi,
|
||||
"peak_gain_dbi": round(peak, 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": model,
|
||||
}
|
||||
|
||||
if frequency_hz:
|
||||
result["wavelength_m"] = round(C / frequency_hz, 4)
|
||||
|
||||
if metadata:
|
||||
result["import_info"] = metadata
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Interpolation ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _great_circle_distance(t1: float, p1: float, t2: float, p2: float) -> float:
|
||||
"""Great-circle angular distance between two (theta, phi) points in degrees.
|
||||
|
||||
Uses the Vincenty formula for numerical stability at small/large distances.
|
||||
"""
|
||||
t1r, p1r = math.radians(t1), math.radians(p1)
|
||||
t2r, p2r = math.radians(t2), math.radians(p2)
|
||||
dp = p2r - p1r
|
||||
|
||||
sin_t1, cos_t1 = math.sin(t1r), math.cos(t1r)
|
||||
sin_t2, cos_t2 = math.sin(t2r), math.cos(t2r)
|
||||
sin_dp, cos_dp = math.sin(dp), math.cos(dp)
|
||||
|
||||
num = math.sqrt((cos_t2 * sin_dp) ** 2 + (cos_t1 * sin_t2 - sin_t1 * cos_t2 * cos_dp) ** 2)
|
||||
den = sin_t1 * sin_t2 + cos_t1 * cos_t2 * cos_dp
|
||||
|
||||
return math.degrees(math.atan2(num, den))
|
||||
|
||||
|
||||
def interpolate_to_grid(
|
||||
measurements: list[tuple[float, float, float]],
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
) -> tuple[list[float], list[float], list[list[float]]]:
|
||||
"""Interpolate sparse measurements to a regular theta/phi grid.
|
||||
|
||||
Uses inverse-distance weighting (IDW) with great-circle distance on the
|
||||
sphere surface. K=6 nearest neighbors, power=2 (Shepard's method).
|
||||
|
||||
Args:
|
||||
measurements: List of (theta_deg, phi_deg, gain_dbi) tuples
|
||||
theta_step: Output grid polar angle step (degrees)
|
||||
phi_step: Output grid azimuthal angle step (degrees)
|
||||
|
||||
Returns:
|
||||
(thetas, phis, gain_grid) where gain_grid[i][j] is gain in dBi
|
||||
"""
|
||||
thetas = _deg_range(0.0, 180.0, theta_step)
|
||||
phis = _deg_range(0.0, 355.0, phi_step)
|
||||
k = min(6, len(measurements))
|
||||
|
||||
gain_grid: list[list[float]] = []
|
||||
for theta in thetas:
|
||||
row: list[float] = []
|
||||
for phi in phis:
|
||||
# Compute distances to all measurement points
|
||||
dists = []
|
||||
for mt, mp, mg in measurements:
|
||||
d = _great_circle_distance(theta, phi, mt, mp)
|
||||
dists.append((d, mg))
|
||||
|
||||
# Exact match shortcut
|
||||
dists.sort(key=lambda x: x[0])
|
||||
if dists[0][0] < 0.01:
|
||||
row.append(dists[0][1])
|
||||
continue
|
||||
|
||||
# IDW with K nearest neighbors
|
||||
nearest = dists[:k]
|
||||
w_sum = 0.0
|
||||
g_sum = 0.0
|
||||
for d, g in nearest:
|
||||
w = 1.0 / (d * d + 1e-10)
|
||||
w_sum += w
|
||||
g_sum += w * g
|
||||
row.append(g_sum / w_sum)
|
||||
gain_grid.append(row)
|
||||
|
||||
return thetas, phis, gain_grid
|
||||
|
||||
|
||||
def single_cut_to_3d(
|
||||
angles_deg: list[float],
|
||||
gain_db: list[float],
|
||||
cut_plane: str = "azimuth",
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
) -> tuple[list[float], list[float], list[list[float]]]:
|
||||
"""Synthesize a 3D pattern from a single-plane cut measurement.
|
||||
|
||||
For azimuth cuts (typical of EMCAR): the measured data becomes the H-plane
|
||||
(theta=90 deg), and elevation is synthesized with a sin(theta) taper —
|
||||
physically approximate but produces a useful 3D shape.
|
||||
|
||||
For elevation cuts: the measured data becomes the E-plane (phi=0 deg),
|
||||
and the pattern is rotated around the vertical axis.
|
||||
|
||||
Args:
|
||||
angles_deg: Measurement angles in degrees
|
||||
gain_db: Gain values in dB at each angle
|
||||
cut_plane: "azimuth" (H-plane) or "elevation" (E-plane)
|
||||
theta_step: Output grid polar angle step
|
||||
phi_step: Output grid azimuthal angle step
|
||||
"""
|
||||
thetas = _deg_range(0.0, 180.0, theta_step)
|
||||
phis = _deg_range(0.0, 355.0, phi_step)
|
||||
|
||||
# Build a lookup for the measured cut via linear interpolation
|
||||
def _interp_cut(angle: float) -> float:
|
||||
"""Linearly interpolate the measured cut at an arbitrary angle."""
|
||||
# Normalize to [0, 360)
|
||||
angle = angle % 360.0
|
||||
n = len(angles_deg)
|
||||
if n == 0:
|
||||
return -40.0
|
||||
if n == 1:
|
||||
return gain_db[0]
|
||||
|
||||
# Find bracketing points
|
||||
normed = [a % 360.0 for a in angles_deg]
|
||||
for i in range(n - 1):
|
||||
a0, a1 = normed[i], normed[i + 1]
|
||||
if a0 <= angle <= a1 and a1 > a0:
|
||||
t = (angle - a0) / (a1 - a0)
|
||||
return gain_db[i] * (1 - t) + gain_db[i + 1] * t
|
||||
|
||||
# Wrap-around interpolation
|
||||
return gain_db[-1]
|
||||
|
||||
gain_grid: list[list[float]] = []
|
||||
|
||||
if cut_plane == "azimuth":
|
||||
# Measured data is H-plane (theta=90). Synthesize elevation via sin(theta) taper.
|
||||
for theta in thetas:
|
||||
sin_t = math.sin(math.radians(theta))
|
||||
taper = max(sin_t, 0.01) # Avoid -inf at poles
|
||||
taper_db = 20.0 * math.log10(taper)
|
||||
row = [_interp_cut(phi) + taper_db for phi in phis]
|
||||
gain_grid.append(row)
|
||||
else:
|
||||
# Elevation cut: measured data defines E-plane, rotate around phi
|
||||
for theta in thetas:
|
||||
g_at_theta = _interp_cut(theta)
|
||||
row = [g_at_theta] * len(phis)
|
||||
gain_grid.append(row)
|
||||
|
||||
return thetas, phis, gain_grid
|
||||
|
||||
|
||||
# ── Format parsers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_csv_pattern(content: str, frequency_hz: float | None = None) -> dict:
|
||||
"""Parse a CSV file with antenna pattern data.
|
||||
|
||||
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from the
|
||||
header row. Flexible header names: theta/elevation, phi/azimuth,
|
||||
gain/gain_dbi/amplitude/db/dbi.
|
||||
|
||||
For 2-column data, the angle column name determines the cut plane:
|
||||
- "phi" or "azimuth" → azimuth cut
|
||||
- "theta" or "elevation" → elevation cut
|
||||
|
||||
Args:
|
||||
content: CSV file content as string
|
||||
frequency_hz: Operating frequency in Hz (optional, for metadata)
|
||||
"""
|
||||
lines = [line.strip() for line in content.strip().splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise ValueError("Empty CSV content")
|
||||
|
||||
# Parse header
|
||||
header = lines[0].lower().replace(" ", "_")
|
||||
cols = re.split(r"[,;\t]+", header)
|
||||
|
||||
# Identify column indices
|
||||
theta_idx = phi_idx = gain_idx = -1
|
||||
angle_idx = -1 # For 2-column mode
|
||||
|
||||
for i, col in enumerate(cols):
|
||||
col_clean = col.strip()
|
||||
if col_clean in ("theta", "theta_deg", "elevation", "el"):
|
||||
theta_idx = i
|
||||
elif col_clean in ("phi", "phi_deg", "azimuth", "az"):
|
||||
phi_idx = i
|
||||
elif col_clean in ("gain", "gain_dbi", "dbi", "db", "amplitude", "gain_db", "magnitude"):
|
||||
gain_idx = i
|
||||
elif col_clean in ("angle", "angle_deg", "deg", "degrees"):
|
||||
angle_idx = i
|
||||
|
||||
# Determine mode
|
||||
three_col = theta_idx >= 0 and phi_idx >= 0 and gain_idx >= 0
|
||||
two_col = not three_col and gain_idx >= 0 and (theta_idx >= 0 or phi_idx >= 0 or angle_idx >= 0)
|
||||
|
||||
if not three_col and not two_col:
|
||||
# Try numeric-only (no header): assume 2-col azimuth or 3-col
|
||||
first_data = re.split(r"[,;\t]+", lines[0])
|
||||
try:
|
||||
vals = [float(v) for v in first_data]
|
||||
if len(vals) >= 3:
|
||||
three_col = True
|
||||
theta_idx, phi_idx, gain_idx = 0, 1, 2
|
||||
lines = lines # No header to skip
|
||||
elif len(vals) == 2:
|
||||
two_col = True
|
||||
angle_idx, gain_idx = 0, 1
|
||||
phi_idx = -1
|
||||
lines = lines
|
||||
else:
|
||||
raise ValueError(f"Cannot parse CSV: unrecognized columns: {cols}")
|
||||
except ValueError:
|
||||
raise ValueError(f"Cannot parse CSV: unrecognized header columns: {cols}")
|
||||
else:
|
||||
lines = lines[1:] # Skip header
|
||||
|
||||
# Parse data
|
||||
if three_col:
|
||||
measurements: list[tuple[float, float, float]] = []
|
||||
for line in lines:
|
||||
parts = re.split(r"[,;\t]+", line.strip())
|
||||
if len(parts) < max(theta_idx, phi_idx, gain_idx) + 1:
|
||||
continue
|
||||
try:
|
||||
t = float(parts[theta_idx])
|
||||
p = float(parts[phi_idx])
|
||||
g = float(parts[gain_idx])
|
||||
measurements.append((t, p, g))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not measurements:
|
||||
raise ValueError("No valid data rows found in CSV")
|
||||
|
||||
thetas, phis, gain_grid = interpolate_to_grid(measurements)
|
||||
return _build_pattern_dict(
|
||||
thetas,
|
||||
phis,
|
||||
gain_grid,
|
||||
model="imported_csv",
|
||||
frequency_hz=frequency_hz,
|
||||
antenna_type="imported",
|
||||
metadata={"format": "csv", "points": len(measurements), "columns": 3},
|
||||
)
|
||||
|
||||
else:
|
||||
# 2-column mode
|
||||
use_idx = theta_idx if theta_idx >= 0 else (phi_idx if phi_idx >= 0 else angle_idx)
|
||||
cut_plane = "elevation" if theta_idx >= 0 else "azimuth"
|
||||
|
||||
angles: list[float] = []
|
||||
gains: list[float] = []
|
||||
for line in lines:
|
||||
parts = re.split(r"[,;\t]+", line.strip())
|
||||
if len(parts) < max(use_idx, gain_idx) + 1:
|
||||
continue
|
||||
try:
|
||||
a = float(parts[use_idx])
|
||||
g = float(parts[gain_idx])
|
||||
angles.append(a)
|
||||
gains.append(g)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not angles:
|
||||
raise ValueError("No valid data rows found in CSV")
|
||||
|
||||
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane=cut_plane)
|
||||
result = _build_pattern_dict(
|
||||
thetas,
|
||||
phis,
|
||||
gain_grid,
|
||||
model="imported_csv_single_cut",
|
||||
frequency_hz=frequency_hz,
|
||||
antenna_type="imported",
|
||||
metadata={"format": "csv", "points": len(angles), "columns": 2, "cut_plane": cut_plane},
|
||||
)
|
||||
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": cut_plane}
|
||||
return result
|
||||
|
||||
|
||||
def parse_emcar_vna_dat(
|
||||
content: str,
|
||||
frequency_hz: float | None = None,
|
||||
reference_dbi: float | None = None,
|
||||
) -> dict:
|
||||
"""Parse EMCAR vna.dat format (angle + amplitude_dBV pairs).
|
||||
|
||||
EMCAR records raw ADC voltage from the HP8754A VNA. Lines starting with #
|
||||
are comments. Data is whitespace-separated: angle amplitude.
|
||||
|
||||
The gnuplot transform from EMCAR's plotting scripts is applied:
|
||||
- Angle rotation: (-angle + 90) to convert from positioner coords to antenna coords
|
||||
- Amplitude: 20*log10(amplitude + 0.01) to convert voltage to dB scale
|
||||
|
||||
Without a reference antenna, this shows relative pattern shape only.
|
||||
Pass reference_dbi to offset the entire pattern to absolute gain.
|
||||
|
||||
Args:
|
||||
content: vna.dat file content as string
|
||||
frequency_hz: Operating frequency in Hz (optional)
|
||||
reference_dbi: Offset to apply for absolute gain calibration (optional)
|
||||
"""
|
||||
angles: list[float] = []
|
||||
gains: list[float] = []
|
||||
|
||||
for line in content.strip().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
angle = float(parts[0])
|
||||
amplitude = float(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# EMCAR gnuplot transform
|
||||
rotated_angle = (-angle + 90.0) % 360.0
|
||||
# Voltage to dB: 20*log10(amplitude + 0.01) — the +0.01 prevents log(0)
|
||||
gain_db = 20.0 * math.log10(abs(amplitude) + 0.01)
|
||||
|
||||
if reference_dbi is not None:
|
||||
gain_db += reference_dbi
|
||||
|
||||
angles.append(rotated_angle)
|
||||
gains.append(gain_db)
|
||||
|
||||
if not angles:
|
||||
raise ValueError("No valid data rows found in EMCAR vna.dat content")
|
||||
|
||||
# Sort by angle for clean interpolation
|
||||
paired = sorted(zip(angles, gains), key=lambda x: x[0])
|
||||
angles = [a for a, _ in paired]
|
||||
gains = [g for _, g in paired]
|
||||
|
||||
thetas, phis, gain_grid = single_cut_to_3d(angles, gains, cut_plane="azimuth")
|
||||
|
||||
result = _build_pattern_dict(
|
||||
thetas,
|
||||
phis,
|
||||
gain_grid,
|
||||
model="imported_emcar",
|
||||
frequency_hz=frequency_hz,
|
||||
antenna_type="imported",
|
||||
metadata={
|
||||
"format": "emcar",
|
||||
"points": len(angles),
|
||||
"reference_dbi": reference_dbi,
|
||||
"note": "Relative pattern shape" if reference_dbi is None else "Calibrated with reference",
|
||||
},
|
||||
)
|
||||
result["raw_cut"] = {"angles_deg": angles, "gain_db": gains, "plane": "azimuth"}
|
||||
return result
|
||||
|
||||
|
||||
def parse_nec2_radiation(content: str, polarization: str = "total") -> dict:
|
||||
"""Parse NEC2 radiation pattern output.
|
||||
|
||||
Finds the "RADIATION PATTERNS" section in NEC2 output files and extracts
|
||||
the theta/phi/gain grid. Supports TOTAL, VERT, and HOR polarization selection.
|
||||
|
||||
NEC2 output columns (after the header):
|
||||
THETA PHI VERT(dB) HOR(dB) TOTAL(dB) AXIAL_RATIO TILT SENSE
|
||||
|
||||
Values of -999.99 are mapped to a -40 dBi floor.
|
||||
|
||||
Args:
|
||||
content: NEC2 output file content as string
|
||||
polarization: Which gain column to use: "total", "vert", or "hor"
|
||||
"""
|
||||
lines = content.splitlines()
|
||||
frequency_hz: float | None = None
|
||||
measurements: list[tuple[float, float, float]] = []
|
||||
|
||||
# Column index for the selected polarization
|
||||
pol_map = {"vert": 0, "hor": 1, "total": 2}
|
||||
if polarization.lower() not in pol_map:
|
||||
raise ValueError(f"polarization must be 'total', 'vert', or 'hor', got '{polarization}'")
|
||||
pol_col = pol_map[polarization.lower()]
|
||||
|
||||
in_pattern = False
|
||||
header_lines_remaining = 0
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Look for frequency in the header area
|
||||
freq_match = re.search(r"FREQUENCY\s*[=:]\s*([\d.]+)\s*(MHZ|MHz|GHZ|GHz|HZ|Hz)", stripped, re.IGNORECASE)
|
||||
if freq_match:
|
||||
freq_val = float(freq_match.group(1))
|
||||
unit = freq_match.group(2).upper()
|
||||
if unit == "MHZ":
|
||||
frequency_hz = freq_val * 1e6
|
||||
elif unit == "GHZ":
|
||||
frequency_hz = freq_val * 1e9
|
||||
else:
|
||||
frequency_hz = freq_val
|
||||
|
||||
# Detect start of radiation pattern section
|
||||
if "RADIATION PATTERNS" in stripped.upper():
|
||||
in_pattern = True
|
||||
header_lines_remaining = 3 # Skip title + column headers + separator
|
||||
continue
|
||||
|
||||
if in_pattern:
|
||||
if header_lines_remaining > 0:
|
||||
header_lines_remaining -= 1
|
||||
continue
|
||||
|
||||
# End of section: blank line or new section header
|
||||
if not stripped or stripped.startswith("*") or stripped.startswith("-" * 10):
|
||||
if measurements:
|
||||
break
|
||||
continue
|
||||
|
||||
# Parse data line
|
||||
parts = stripped.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
try:
|
||||
theta = float(parts[0])
|
||||
phi = float(parts[1])
|
||||
# Columns 2,3,4 are VERT(dB), HOR(dB), TOTAL(dB)
|
||||
gain_val = float(parts[2 + pol_col])
|
||||
|
||||
# NEC2 uses -999.99 for undefined/zero
|
||||
if gain_val < -900:
|
||||
gain_val = -40.0
|
||||
|
||||
measurements.append((theta, phi, gain_val))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not measurements:
|
||||
raise ValueError("No radiation pattern data found in NEC2 output")
|
||||
|
||||
thetas, phis, gain_grid = interpolate_to_grid(measurements)
|
||||
|
||||
return _build_pattern_dict(
|
||||
thetas,
|
||||
phis,
|
||||
gain_grid,
|
||||
model="imported_nec2",
|
||||
frequency_hz=frequency_hz,
|
||||
antenna_type="imported",
|
||||
metadata={
|
||||
"format": "nec2",
|
||||
"points": len(measurements),
|
||||
"polarization": polarization.lower(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def parse_touchstone_s1p(
|
||||
content: str,
|
||||
antenna_type: str = "auto",
|
||||
) -> dict:
|
||||
"""Parse Touchstone S1P file and generate an analytical pattern at resonance.
|
||||
|
||||
Reads S11 complex data, finds the resonant frequency (minimum |S11|),
|
||||
computes impedance, estimates antenna type, and delegates to
|
||||
radiation.generate_3d_pattern(). This bridges imported S1P data to the
|
||||
Phase 1 analytical pattern engine.
|
||||
|
||||
Args:
|
||||
content: Touchstone .s1p file content as string
|
||||
antenna_type: Antenna model, or 'auto' to estimate from impedance
|
||||
"""
|
||||
from mcnanovna.radiation import estimate_antenna_type, generate_3d_pattern
|
||||
|
||||
lines = content.strip().splitlines()
|
||||
freq_mult = 1.0
|
||||
data_format = "ri" # Default: real/imaginary
|
||||
z0 = 50.0
|
||||
|
||||
frequencies: list[float] = []
|
||||
s11_complex: list[complex] = []
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("!"):
|
||||
continue
|
||||
|
||||
# Option line: # Hz S RI R 50
|
||||
if line.startswith("#"):
|
||||
parts = line[1:].strip().upper().split()
|
||||
for i, p in enumerate(parts):
|
||||
if p in ("HZ", "KHZ", "MHZ", "GHZ"):
|
||||
freq_mult = {"HZ": 1.0, "KHZ": 1e3, "MHZ": 1e6, "GHZ": 1e9}[p]
|
||||
elif p in ("RI", "MA", "DB"):
|
||||
data_format = p.lower()
|
||||
elif p == "R" and i + 1 < len(parts):
|
||||
try:
|
||||
z0 = float(parts[i + 1])
|
||||
except ValueError:
|
||||
pass
|
||||
continue
|
||||
|
||||
# Data line
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
freq = float(parts[0]) * freq_mult
|
||||
v1, v2 = float(parts[1]), float(parts[2])
|
||||
|
||||
if data_format == "ri":
|
||||
s11 = complex(v1, v2)
|
||||
elif data_format == "ma":
|
||||
s11 = v1 * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
|
||||
elif data_format == "db":
|
||||
mag = 10.0 ** (v1 / 20.0)
|
||||
s11 = mag * complex(math.cos(math.radians(v2)), math.sin(math.radians(v2)))
|
||||
else:
|
||||
s11 = complex(v1, v2)
|
||||
|
||||
frequencies.append(freq)
|
||||
s11_complex.append(s11)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not frequencies:
|
||||
raise ValueError("No valid S-parameter data found in Touchstone file")
|
||||
|
||||
# Find resonance: minimum |S11|
|
||||
min_mag = 999.0
|
||||
min_idx = 0
|
||||
for i, s in enumerate(s11_complex):
|
||||
mag = abs(s)
|
||||
if mag < min_mag:
|
||||
min_mag = mag
|
||||
min_idx = i
|
||||
|
||||
res_freq = frequencies[min_idx]
|
||||
s11_res = s11_complex[min_idx]
|
||||
|
||||
# Compute impedance at resonance: Z = Z0 * (1+S11) / (1-S11)
|
||||
denom = 1.0 - s11_res
|
||||
if abs(denom) < 1e-10:
|
||||
z_real, z_imag = 9999.0, 0.0
|
||||
else:
|
||||
z = z0 * (1.0 + s11_res) / denom
|
||||
z_real, z_imag = z.real, z.imag
|
||||
|
||||
# Estimate antenna type if needed
|
||||
if antenna_type == "auto":
|
||||
antenna_type = estimate_antenna_type(z_real, z_imag, res_freq)
|
||||
|
||||
pattern = generate_3d_pattern(
|
||||
antenna_type=antenna_type,
|
||||
frequency_hz=res_freq,
|
||||
s11_analysis={
|
||||
"resonance": {
|
||||
"frequency_hz": res_freq,
|
||||
"impedance_real": round(z_real, 2),
|
||||
"impedance_imag": round(z_imag, 2),
|
||||
"s11_magnitude": round(min_mag, 4),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
pattern["model"] = "imported_s1p_analytical"
|
||||
pattern["import_info"] = {
|
||||
"format": "s1p",
|
||||
"points": len(frequencies),
|
||||
"frequency_range_hz": [frequencies[0], frequencies[-1]],
|
||||
"resonant_frequency_hz": res_freq,
|
||||
"impedance_at_resonance": {"real": round(z_real, 2), "imag": round(z_imag, 2)},
|
||||
"z0": z0,
|
||||
}
|
||||
|
||||
return pattern
|
||||
|
||||
|
||||
# ── Format auto-detection ─────────────────────────────────────────
|
||||
|
||||
|
||||
def detect_format(filename: str, content: str | None = None) -> str:
|
||||
"""Detect pattern file format from filename extension and optional content inspection.
|
||||
|
||||
Returns one of: "csv", "emcar", "nec2", "s1p"
|
||||
"""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
|
||||
if ext == "csv":
|
||||
return "csv"
|
||||
if ext == "dat":
|
||||
return "emcar"
|
||||
if ext in ("out", "nec"):
|
||||
return "nec2"
|
||||
if ext == "s1p":
|
||||
return "s1p"
|
||||
|
||||
# Content-based detection fallback
|
||||
if content:
|
||||
if "RADIATION PATTERNS" in content.upper():
|
||||
return "nec2"
|
||||
if content.lstrip().startswith("#") and re.search(r"#\s*\w+\s+S\s+", content[:200]):
|
||||
return "s1p"
|
||||
if re.match(r"[\w_]+[,;\t]", content.lstrip().split("\n")[0]):
|
||||
return "csv"
|
||||
|
||||
raise ValueError(f"Cannot detect format from filename '{filename}'. Use .csv, .dat, .out, .nec, or .s1p")
|
||||
|
||||
|
||||
def parse_pattern(
|
||||
content: str,
|
||||
format: str,
|
||||
frequency_hz: float | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Parse a pattern file with explicit format selection.
|
||||
|
||||
Convenience dispatcher that routes to the appropriate parser.
|
||||
|
||||
Args:
|
||||
content: File content as string
|
||||
format: One of "csv", "emcar", "nec2", "s1p"
|
||||
frequency_hz: Operating frequency (used by CSV and EMCAR)
|
||||
**kwargs: Additional format-specific arguments
|
||||
"""
|
||||
if format == "csv":
|
||||
return parse_csv_pattern(content, frequency_hz=frequency_hz)
|
||||
elif format == "emcar":
|
||||
return parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=kwargs.get("reference_dbi"))
|
||||
elif format == "nec2":
|
||||
return parse_nec2_radiation(content, polarization=kwargs.get("polarization", "total"))
|
||||
elif format == "s1p":
|
||||
return parse_touchstone_s1p(content, antenna_type=kwargs.get("antenna_type", "auto"))
|
||||
else:
|
||||
raise ValueError(f"Unknown format '{format}'. Supported: csv, emcar, nec2, s1p")
|
||||
@ -156,19 +156,19 @@ Let me start by setting the sweep range. Ready?""",
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} – {_format_freq(stop_hz)}
|
||||
**Points**: {points}
|
||||
**Format**: {'S2P (S11 + S21 — requires DUT connected between ports)' if s2p else 'S1P (S11 reflection only)'}
|
||||
**Format**: {"S2P (S11 + S21 — requires DUT connected between ports)" if s2p else "S1P (S11 reflection only)"}
|
||||
|
||||
**Touchstone format reference** (IEEE Std 1363):
|
||||
```
|
||||
! NanoVNA-H measurement
|
||||
# Hz S RI R 50
|
||||
! freq {'s11_re s11_im s21_re s21_im s12_re s12_im s22_re s22_im' if s2p else 's11_re s11_im'}
|
||||
! freq {"s11_re s11_im s21_re s21_im s12_re s12_im s22_re s22_im" if s2p else "s11_re s11_im"}
|
||||
```
|
||||
|
||||
I'll:
|
||||
1. Run a `scan` from {start_hz} to {stop_hz} with {points} points{', capturing both S11 and S21' if s2p else ', S11 only'}
|
||||
1. Run a `scan` from {start_hz} to {stop_hz} with {points} points{", capturing both S11 and S21" if s2p else ", S11 only"}
|
||||
2. Format each data point as real/imaginary pairs
|
||||
3. {'For S2P: S12 and S22 will be set to 0+j0 (NanoVNA is a 1-port/2-port device that measures S11 and S21 only)' if s2p else 'Each line: frequency S11_real S11_imag'}
|
||||
3. {"For S2P: S12 and S22 will be set to 0+j0 (NanoVNA is a 1-port/2-port device that measures S11 and S21 only)" if s2p else "Each line: frequency S11_real S11_imag"}
|
||||
4. Present the complete file content for you to save
|
||||
|
||||
Let me run the scan now.""",
|
||||
@ -206,10 +206,7 @@ Let me run the scan now.""",
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Analyze my antenna on the {band_label} "
|
||||
f"({_format_freq(f_start)} – {_format_freq(f_stop)})."
|
||||
),
|
||||
content=(f"Analyze my antenna on the {band_label} ({_format_freq(f_start)} – {_format_freq(f_stop)})."),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
@ -258,8 +255,7 @@ Let me run the scan and analysis now.""",
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Analyze a cable/transmission line from "
|
||||
f"{_format_freq(start_hz)} to {_format_freq(stop_hz)}."
|
||||
f"Analyze a cable/transmission line from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
@ -290,6 +286,202 @@ Let me start with the open-ended measurement. Is the cable connected with the fa
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def analyze_crystal(
|
||||
frequency_hz: int = 10_000_000,
|
||||
span_hz: int = 100_000,
|
||||
points: int = 201,
|
||||
) -> list[Message]:
|
||||
"""Guide through quartz crystal parameter extraction.
|
||||
|
||||
Measures a crystal's motional parameters (Rm, Lm, Cm, Cp) and Q factor
|
||||
using S21 transmission through a series test fixture.
|
||||
|
||||
Args:
|
||||
frequency_hz: Nominal crystal frequency in Hz (e.g. 10000000 for 10 MHz)
|
||||
span_hz: Frequency span around nominal (e.g. 100000 for +/-50 kHz)
|
||||
points: Number of measurement points (201 recommended for resolution)
|
||||
"""
|
||||
start = frequency_hz - span_hz // 2
|
||||
stop = frequency_hz + span_hz // 2
|
||||
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Measure and extract parameters for a {_format_freq(frequency_hz)} "
|
||||
f"crystal with {_format_freq(span_hz)} span."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll guide you through crystal parameter extraction using the series-jig method.
|
||||
|
||||
**Target crystal**: {_format_freq(frequency_hz)} (nominal)
|
||||
**Scan range**: {_format_freq(start)} – {_format_freq(stop)} ({points} points)
|
||||
|
||||
**Test fixture setup** (series measurement jig):
|
||||
```
|
||||
Port 1 ──┤├── Crystal ──┤├── Port 2
|
||||
SMA SMA
|
||||
```
|
||||
The crystal is placed in series between the two ports. S21 transmission
|
||||
peaks at the series resonance frequency (fs) where the crystal's motional
|
||||
impedance is at minimum.
|
||||
|
||||
**What I'll extract:**
|
||||
- **fs** — Series resonance frequency (max S21 transmission)
|
||||
- **fp** — Parallel resonance frequency (min S21 transmission, anti-resonance)
|
||||
- **Rm** — Motional resistance (ESR at resonance)
|
||||
- **Lm** — Motional inductance
|
||||
- **Cm** — Motional capacitance
|
||||
- **Cp** — Holder/shunt capacitance (from fs-fp spacing)
|
||||
- **Q** — Quality factor (typically 10,000–100,000 for quartz)
|
||||
- **Insertion loss** at series resonance
|
||||
|
||||
**Tips for accurate measurements:**
|
||||
- Calibrate first (the `calibrate` prompt helps)
|
||||
- Use short SMA cables to minimize fixture parasitics
|
||||
- A span of 2-5x the crystal bandwidth gives good resolution
|
||||
- For narrow-bandwidth crystals (high Q), increase points to 201 or 401
|
||||
|
||||
**Procedure:**
|
||||
1. Connect the crystal in the series test jig between Port 1 and Port 2
|
||||
2. I'll run `analyze_xtal` with the scan parameters
|
||||
3. I'll present the extracted motional equivalent circuit parameters
|
||||
|
||||
Ready? Let me start the measurement.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def analyze_filter_response(
|
||||
start_hz: int = 1_000_000,
|
||||
stop_hz: int = 500_000_000,
|
||||
points: int = 201,
|
||||
) -> list[Message]:
|
||||
"""Guide through filter characterization and classification.
|
||||
|
||||
Measures S21 transmission through a filter to determine type,
|
||||
cutoff frequencies, bandwidth, Q factor, and roll-off rate.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (201 recommended)
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Characterize a filter from {_format_freq(start_hz)} to "
|
||||
f"{_format_freq(stop_hz)} with {points} points."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll measure and classify your filter using S21 transmission analysis.
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} – {_format_freq(stop_hz)} ({points} points)
|
||||
|
||||
**Setup:**
|
||||
Connect the filter between Port 1 (input) and Port 2 (output).
|
||||
Ensure proper impedance matching (50\u03a9 typically).
|
||||
|
||||
**What I'll determine:**
|
||||
- **Filter type**: lowpass, highpass, bandpass, or bandstop
|
||||
- **Cutoff frequencies** at -3dB, -6dB, -10dB, and -20dB thresholds
|
||||
- **Bandwidth** at -3dB and -6dB (for bandpass filters)
|
||||
- **Center frequency** and **Q factor** (for bandpass filters)
|
||||
- **Roll-off rate** in dB/decade and dB/octave (skirt steepness)
|
||||
- **Peak insertion loss** (passband flatness)
|
||||
|
||||
**Interpreting results:**
|
||||
- **Roll-off** indicates filter order: ~20 dB/decade per pole (1st order = 20, 2nd = 40, etc.)
|
||||
- **Q factor** for bandpass: higher Q = narrower bandwidth relative to center frequency
|
||||
- **Insertion loss** in passband: lower is better (<1 dB for good passive filters)
|
||||
- Compare -3dB and -6dB bandwidths to assess skirt shape
|
||||
|
||||
**Scan tips:**
|
||||
- Start well below the expected passband for complete characterization
|
||||
- Extend stop frequency to capture the full stopband roll-off
|
||||
- Use 201+ points for accurate cutoff interpolation
|
||||
|
||||
Let me run the `analyze_filter` tool now.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def measure_tdr(
|
||||
start_hz: int = 100_000,
|
||||
stop_hz: int = 900_000_000,
|
||||
points: int = 201,
|
||||
velocity_factor: float = 0.66,
|
||||
) -> list[Message]:
|
||||
"""Guide through Time Domain Reflectometry cable analysis.
|
||||
|
||||
Scans S11 and transforms to the time/distance domain to reveal
|
||||
impedance discontinuities, cable faults, and connection quality.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz (lower = longer range)
|
||||
stop_hz: Stop frequency in Hz (higher = better resolution)
|
||||
points: Number of measurement points (201 recommended)
|
||||
velocity_factor: Cable velocity factor (0.66 for RG-58, see table)
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Run TDR analysis from {_format_freq(start_hz)} to "
|
||||
f"{_format_freq(stop_hz)} with VF={velocity_factor}."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll perform Time Domain Reflectometry to map impedance along your cable.
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} – {_format_freq(stop_hz)} ({points} points)
|
||||
**Velocity factor**: {velocity_factor}
|
||||
|
||||
**How TDR works:**
|
||||
S11 reflection data across a wide frequency span is transformed to the
|
||||
time domain via inverse FFT. Each time delay corresponds to a physical
|
||||
distance along the cable. Impedance changes (connectors, damage, open/short
|
||||
ends) appear as reflection peaks at their physical location.
|
||||
|
||||
**Setup:**
|
||||
Connect one end of the cable to Port 1 (CH0). The far end can be:
|
||||
- **Open** — large positive reflection at the end
|
||||
- **Shorted** — large negative reflection at the end
|
||||
- **Terminated (50\u03a9)** — no reflection at the end (only see faults)
|
||||
|
||||
**Velocity factor reference** (VF determines distance accuracy):
|
||||
| Cable Type | VF |
|
||||
|---|---|
|
||||
| RG-58, RG-8 (solid PE) | 0.66 |
|
||||
| RG-213 (solid PE) | 0.66 |
|
||||
| LMR-400 | 0.85 |
|
||||
| Foam PE dielectric | 0.82 |
|
||||
| Air dielectric / hardline | 0.95–0.97 |
|
||||
| RG-174 | 0.66 |
|
||||
| Belden 9913 | 0.84 |
|
||||
|
||||
**Resolution and range** (determined by frequency span):
|
||||
- Resolution \u2248 c \u00d7 VF / (2 \u00d7 span) — wider span = finer detail
|
||||
- Max range \u2248 c \u00d7 VF \u00d7 (N-1) / (2 \u00d7 span) — more points = longer range
|
||||
- With {_format_freq(stop_hz - start_hz)} span and VF={velocity_factor}:
|
||||
~{299_792_458 * velocity_factor / (2 * (stop_hz - start_hz)):.2f} m resolution
|
||||
|
||||
**Window options:**
|
||||
- **minimum** (beta=0): sharpest peaks but more sidelobe ringing
|
||||
- **normal** (beta=6): good balance of resolution and sidelobe suppression
|
||||
- **maximum** (beta=13): smoothest response, wider peaks
|
||||
|
||||
Let me run `analyze_tdr` now.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def compare_sweeps(
|
||||
start_hz: int = 1_000_000,
|
||||
@ -341,3 +533,600 @@ for a valid comparison. Don't change calibration between scans.
|
||||
Set up the **"before"** condition and tell me when ready for scan #1.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def measure_component(
|
||||
start_hz: int = 1_000_000,
|
||||
stop_hz: int = 500_000_000,
|
||||
points: int = 101,
|
||||
) -> list[Message]:
|
||||
"""Guide through identifying an unknown component (inductor, capacitor, or resistor).
|
||||
|
||||
Scans S11 reflection to classify the component, report its value,
|
||||
ESR, Q factor, and self-resonant frequency.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(f"Identify an unknown component from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll identify your component using S11 reflection analysis.
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
|
||||
|
||||
**Setup:**
|
||||
Connect the unknown component to Port 1 using a test fixture or SMA adapter.
|
||||
Port 2 is not used for this measurement.
|
||||
|
||||
**How S11 component identification works:**
|
||||
The VNA measures the complex reflection coefficient (\u0393) at Port 1.
|
||||
From \u0393, I compute the impedance Z = R + jX at each frequency:
|
||||
- **Inductor**: positive reactance (X > 0), increasing with frequency. X = 2\u03c0fL
|
||||
- **Capacitor**: negative reactance (X < 0), magnitude decreasing with frequency. X = \u22121/(2\u03c0fC)
|
||||
- **Resistor**: minimal reactance across the sweep, R dominates
|
||||
- **LC circuit**: reactance crosses zero at the self-resonant frequency (SRF)
|
||||
|
||||
**Frequency range tips:**
|
||||
- **Large inductors** (>\u00b5H): use lower start frequency (100 kHz \u2013 50 MHz)
|
||||
- **Small inductors** (<100 nH): use higher range (50 MHz \u2013 900 MHz)
|
||||
- **Large capacitors** (>100 pF): lower range works well
|
||||
- **Small capacitors** (<10 pF): use higher frequencies
|
||||
- Start wide, then narrow down around the region of interest
|
||||
|
||||
**What I'll report:**
|
||||
- Component type (inductor / capacitor / resistor / LC circuit)
|
||||
- Primary value (nH, pF, or \u03a9)
|
||||
- ESR (equivalent series resistance)
|
||||
- Q factor at the measurement frequency
|
||||
- Self-resonant frequency if the component has one
|
||||
|
||||
Let me run `analyze_component` now.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def measure_lc_series(
|
||||
start_hz: int = 1_000_000,
|
||||
stop_hz: int = 500_000_000,
|
||||
points: int = 201,
|
||||
measure_r: float = 50.0,
|
||||
) -> list[Message]:
|
||||
"""Guide through series LC resonator measurement.
|
||||
|
||||
Measures a component in series between Port 1 and Port 2 using S21
|
||||
transmission. At resonance, the series LC has minimum impedance and
|
||||
maximum S21 transmission.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (201 recommended)
|
||||
measure_r: Port termination resistance in ohms (default 50)
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(f"Measure a series LC resonator from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll characterize your series LC resonator using S21 transmission measurement.
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
|
||||
**Port termination**: {measure_r} \u03a9
|
||||
|
||||
**Test fixture setup** (series topology):
|
||||
```
|
||||
Port 1 \u2500\u2500\u2500\u2500\u2500[ DUT ]\u2500\u2500\u2500\u2500\u2500 Port 2
|
||||
in series
|
||||
```
|
||||
The device under test is placed **in the signal path** between Port 1 and
|
||||
Port 2. At the resonant frequency, the series LC has minimum impedance,
|
||||
allowing maximum signal through \u2014 a **peak** in S21.
|
||||
|
||||
**What I'll extract:**
|
||||
- **Resonant frequency** (peak S21 transmission)
|
||||
- **Motional resistance** Rm (series resistance at resonance)
|
||||
- **Inductance** L (from phase bandwidth)
|
||||
- **Capacitance** C (from phase bandwidth)
|
||||
- **Q factor** = 2\u03c0fL/Rm
|
||||
- **Bandwidth** (from \u00b145\u00b0 phase crossings)
|
||||
- **Insertion loss** at resonance
|
||||
|
||||
**Series vs crystal measurement:**
|
||||
This tool extracts the same parameters as crystal analysis but without
|
||||
searching for parallel resonance or holder capacitance. Use `analyze_crystal`
|
||||
for quartz crystals, and this tool for general LC resonators, ceramic
|
||||
resonators, or SAW devices.
|
||||
|
||||
**Tips:**
|
||||
- Use 201+ points for narrow-bandwidth resonators
|
||||
- Center the scan range around the expected resonance
|
||||
- A wider span helps if you're unsure of the exact frequency
|
||||
|
||||
Let me run `analyze_lc_series` now.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def measure_lc_shunt(
|
||||
start_hz: int = 1_000_000,
|
||||
stop_hz: int = 500_000_000,
|
||||
points: int = 201,
|
||||
measure_r: float = 50.0,
|
||||
) -> list[Message]:
|
||||
"""Guide through shunt LC resonator measurement.
|
||||
|
||||
Measures a component connected as a shunt (to ground) using S21
|
||||
transmission. At resonance, the shunt LC absorbs signal, producing
|
||||
a transmission dip.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (201 recommended)
|
||||
measure_r: Port termination resistance in ohms (default 50)
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(f"Measure a shunt LC resonator from {_format_freq(start_hz)} to {_format_freq(stop_hz)}."),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll characterize your shunt LC resonator using S21 transmission measurement.
|
||||
|
||||
**Scan range**: {_format_freq(start_hz)} \u2013 {_format_freq(stop_hz)} ({points} points)
|
||||
**Port termination**: {measure_r} \u03a9
|
||||
|
||||
**Test fixture setup** (shunt topology):
|
||||
```
|
||||
Port 1 \u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500 Port 2
|
||||
\u2502 \u2502
|
||||
[DUT] signal
|
||||
\u2502 path
|
||||
GND
|
||||
```
|
||||
The device under test is connected from the signal path **to ground**
|
||||
(shunt configuration). At resonance, the parallel LC presents maximum
|
||||
impedance to ground, creating an **absorption dip** in S21 transmission.
|
||||
|
||||
**How it differs from series measurement:**
|
||||
| | Series | Shunt |
|
||||
|---|---|---|
|
||||
| S21 at resonance | **Peak** (max transmission) | **Dip** (min transmission) |
|
||||
| DUT placement | In-line between ports | From signal path to ground |
|
||||
| Resonance impedance | Minimum (short) | Maximum (open to ground) |
|
||||
| Typical use | Crystal filters, series traps | Notch filters, EMI suppression |
|
||||
|
||||
**What I'll extract:**
|
||||
- **Resonant frequency** (minimum S21 transmission)
|
||||
- **Motional resistance** Rm (from attenuation depth)
|
||||
- **Inductance** L and **Capacitance** C
|
||||
- **Q factor** = f/bandwidth
|
||||
- **Bandwidth** and **insertion loss**
|
||||
|
||||
**Applications:**
|
||||
- Notch filter characterization
|
||||
- EMI filter evaluation
|
||||
- Parallel resonator tuning
|
||||
- LC tank circuit measurement
|
||||
|
||||
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
|
||||
def impedance_match(
|
||||
frequency_hz: int = 145_000_000,
|
||||
r: float = 25.0,
|
||||
x: float = 15.0,
|
||||
z0: float = 50.0,
|
||||
) -> list[Message]:
|
||||
"""Guide through impedance matching network design.
|
||||
|
||||
Computes L-network solutions to match a load impedance to a target
|
||||
impedance (typically 50 ohm). Can use direct R+jX values or scan S11.
|
||||
|
||||
Args:
|
||||
frequency_hz: Design frequency in Hz
|
||||
r: Load resistance in ohms (example default)
|
||||
x: Load reactance in ohms (example default)
|
||||
z0: Target impedance in ohms (default 50)
|
||||
"""
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Design an impedance matching network for "
|
||||
f"{r}+j{x} \u03a9 at {_format_freq(frequency_hz)}, "
|
||||
f"matching to {z0} \u03a9."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll compute L-network impedance matching solutions for your load.
|
||||
|
||||
**Design parameters:**
|
||||
- **Load impedance**: {r} + j{x} \u03a9
|
||||
- **Target impedance**: {z0} \u03a9
|
||||
- **Frequency**: {_format_freq(frequency_hz)}
|
||||
|
||||
**What is an L-network?**
|
||||
The simplest broadband matching network uses two reactive components
|
||||
(inductors and/or capacitors) arranged in an "L" shape. There are typically
|
||||
2\u20134 valid solutions, each with different component values and bandwidth
|
||||
characteristics.
|
||||
|
||||
**L-network topologies:**
|
||||
```
|
||||
Source shunt \u2500\u2500 Series \u2500\u2500 Load shunt
|
||||
\u2502 \u2502
|
||||
[Zp] \u2500\u2500\u2500[Zs]\u2500\u2500\u2500 [Zp]
|
||||
\u2502 \u2502
|
||||
GND GND
|
||||
```
|
||||
Each solution specifies which positions get an inductor, capacitor, or
|
||||
nothing. The tool returns up to 4 solutions.
|
||||
|
||||
**Two modes available:**
|
||||
|
||||
1. **Direct mode** (no hardware needed): Provide R and X values directly.
|
||||
Use this when you already know the impedance from a previous measurement
|
||||
or from a datasheet.
|
||||
|
||||
2. **Scan mode**: Provide start/stop frequencies and the tool scans S11 to
|
||||
measure the actual impedance at the target frequency. More accurate when
|
||||
the load is available for measurement.
|
||||
|
||||
**Choosing between solutions:**
|
||||
- Prefer solutions with **fewer components** (null entries)
|
||||
- **Capacitor in shunt** + **inductor in series** is the most common topology
|
||||
- Higher-Q solutions give narrower bandwidth (sharper match)
|
||||
- Consider practical component values (avoid sub-nH or sub-pF)
|
||||
|
||||
Let me compute the matching solutions now using `analyze_lc_match`.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def measure_antenna_range(
|
||||
antenna_type: str = "dipole",
|
||||
band: str = "2m",
|
||||
start_hz: int | None = None,
|
||||
stop_hz: int | None = None,
|
||||
points: int = 51,
|
||||
theta_step: float = 5.0,
|
||||
phi_step: float = 10.0,
|
||||
settle_ms: int = 200,
|
||||
) -> list[Message]:
|
||||
"""Guide through automated 3D antenna pattern measurement using positioner + VNA.
|
||||
|
||||
This is a cross-server workflow: mcpositioner (separate MCP server) controls
|
||||
the ESP32 antenna positioner, while this server (mcnanovna) provides VNA
|
||||
measurements. The LLM orchestrates both.
|
||||
|
||||
Args:
|
||||
antenna_type: Antenna label for metadata (e.g., 'dipole', 'yagi', 'measured')
|
||||
band: Ham band name (e.g., '2m', '70cm') or 'custom'
|
||||
start_hz: Start frequency in Hz (overrides band)
|
||||
stop_hz: Stop frequency in Hz (overrides band)
|
||||
points: Number of frequency points per S21 measurement
|
||||
theta_step: Polar angle step in degrees (smaller = higher resolution, longer scan)
|
||||
phi_step: Azimuth step in degrees
|
||||
settle_ms: Milliseconds to wait after each positioner move before measuring
|
||||
"""
|
||||
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)} \u2013 {_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"
|
||||
|
||||
n_theta = int(180 / theta_step) + 1
|
||||
n_phi = int(360 / phi_step)
|
||||
total = n_theta * n_phi
|
||||
est_minutes = total * (settle_ms / 1000.0 + 1.0) / 60
|
||||
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=(
|
||||
f"Measure the 3D radiation pattern of my {antenna_type} antenna "
|
||||
f"on the {band_label} using the positioner and VNA."
|
||||
),
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll guide you through an automated 3D radiation pattern measurement.
|
||||
|
||||
This workflow uses **two MCP servers working together**:
|
||||
- **mcnanovna** \u2014 controls the NanoVNA for S21 measurements (this server)
|
||||
- **mcpositioner** \u2014 controls the ESP32 antenna positioner (separate server)
|
||||
|
||||
You'll need the mcpositioner MCP server running. Add it with:
|
||||
`claude mcp add mcpositioner -- uvx mcpositioner`
|
||||
|
||||
**Grid setup:**
|
||||
- Band: {band_label} ({_format_freq(f_start)} \u2013 {_format_freq(f_stop)})
|
||||
- Grid: {theta_step}\u00b0 theta x {phi_step}\u00b0 phi = {n_theta} x {n_phi} = **{total} points**
|
||||
- VNA scan: {points} frequency points per position
|
||||
- Settle time: {settle_ms} ms after each move
|
||||
- Estimated time: ~{est_minutes:.0f} minutes
|
||||
|
||||
**Hardware setup:**
|
||||
1. ESP32 positioner powered and on WiFi (positioner.local)
|
||||
2. NanoVNA-H connected via USB
|
||||
3. Transmit antenna on NanoVNA Port 1 (stationary, aimed at positioner)
|
||||
4. Antenna under test mounted on positioner, connected to NanoVNA Port 2
|
||||
|
||||
---
|
||||
|
||||
**Step 1: Pre-flight checks**
|
||||
|
||||
Call these tools to verify both systems are ready:
|
||||
- `positioner_status` (mcpositioner) \u2014 verify ESP32 is reachable
|
||||
- `info` (mcnanovna) \u2014 verify VNA is connected
|
||||
|
||||
**Step 2: Home the positioner**
|
||||
|
||||
Call `positioner_home` (mcpositioner) with axis='both'.
|
||||
Wait for it to complete, then verify with `positioner_status`.
|
||||
|
||||
**Step 3: Calibrate VNA**
|
||||
|
||||
If not already calibrated, run a SOLT calibration covering {_format_freq(f_start)} to {_format_freq(f_stop)}.
|
||||
|
||||
**Step 4: Measure the grid**
|
||||
|
||||
For each point in the theta/phi grid, execute this sequence:
|
||||
|
||||
```
|
||||
for theta in [0, {theta_step}, {2 * theta_step}, ..., 180]:
|
||||
for phi in [0, {phi_step}, {2 * phi_step}, ..., {360 - phi_step}]:
|
||||
1. positioner_move(theta, phi, wait=True) [mcpositioner]
|
||||
2. wait {settle_ms}ms for mechanical settling
|
||||
3. scan({f_start}, {f_stop}, {points}, s21=True) [mcnanovna]
|
||||
4. extract peak S21 magnitude in dB from scan result
|
||||
5. record: {{theta_deg: theta, phi_deg: phi, s21_peak_db: peak_db}}
|
||||
```
|
||||
|
||||
**Serpentine optimization:** On odd-numbered theta rows, reverse the phi direction
|
||||
to minimize motor travel.
|
||||
|
||||
**Extracting peak S21 from scan result:**
|
||||
The scan returns data points with s21 complex values (real + imag).
|
||||
magnitude_db = 20 * log10(sqrt(real^2 + imag^2)).
|
||||
Take the maximum dB value across all frequency points.
|
||||
|
||||
**Step 5: Build the pattern dict**
|
||||
|
||||
Assemble measurements into the standard format:
|
||||
```
|
||||
pattern = {{
|
||||
"antenna_type": "{antenna_type}",
|
||||
"frequency_hz": {(f_start + f_stop) / 2},
|
||||
"theta_deg": [list of theta values],
|
||||
"phi_deg": [list of phi values],
|
||||
"gain_dbi": [list of normalized gain values],
|
||||
"peak_gain_dbi": max(gain_values),
|
||||
"num_points": {total},
|
||||
}}
|
||||
```
|
||||
|
||||
**Relative vs. absolute calibration:**
|
||||
- **Relative** (default): gain_dbi[i] = s21_peak_db[i] - max(all s21_peak_db)
|
||||
- **Absolute**: Measure known-gain reference antenna at bore-sight first.
|
||||
|
||||
**Resolution tradeoffs:**
|
||||
| Step size | Grid points | Est. time | Use case |
|
||||
|-----------|------------|-----------|----------|
|
||||
| 10\u00b0 x 20\u00b0 | 19 x 18 = 342 | ~9 min | Quick survey |
|
||||
| 5\u00b0 x 10\u00b0 | 37 x 36 = 1332 | ~33 min | Standard |
|
||||
| 2\u00b0 x 5\u00b0 | 91 x 72 = 6552 | ~164 min | High-res |
|
||||
|
||||
**Web UI**: If mcnanovna's web UI is running (MCNANOVNA_WEB_PORT), the final
|
||||
pattern dict can be sent to /api/pattern/compute for 3D rendering.
|
||||
|
||||
Ready to start? I'll begin with the pre-flight checks on both servers.""",
|
||||
),
|
||||
]
|
||||
|
||||
@mcp.prompt
|
||||
def import_pattern(
|
||||
format: str = "csv",
|
||||
) -> list[Message]:
|
||||
"""Guide through importing a measured or simulated antenna pattern.
|
||||
|
||||
Walks through the process of importing pattern data from external files
|
||||
(CSV, EMCAR vna.dat, NEC2 output, or Touchstone S1P) for 3D visualization.
|
||||
|
||||
Args:
|
||||
format: File format \u2014 'csv', 'emcar', 'nec2', or 's1p'
|
||||
"""
|
||||
format_details = {
|
||||
"csv": {
|
||||
"name": "CSV",
|
||||
"ext": ".csv",
|
||||
"tool": "import_pattern_csv",
|
||||
"desc": (
|
||||
"Comma/semicolon/tab-separated with flexible headers.\n"
|
||||
"- **3-column**: theta, phi, gain_dbi \u2014 full 3D pattern\n"
|
||||
"- **2-column**: angle, gain \u2014 single cut, synthesized to 3D"
|
||||
),
|
||||
"example": "theta,phi,gain_dbi\\n0,0,-40\\n90,0,2.15\\n90,90,2.15\\n180,0,-40",
|
||||
},
|
||||
"emcar": {
|
||||
"name": "EMCAR vna.dat",
|
||||
"ext": ".dat",
|
||||
"tool": "import_pattern_emcar",
|
||||
"desc": (
|
||||
"EMCAR antenna range format \u2014 angle + amplitude pairs.\n"
|
||||
"Single azimuth cut from a positioner-driven measurement.\n"
|
||||
"The gnuplot transform is applied automatically:\n"
|
||||
"(-angle+90) rotation and 20*log10(amplitude+0.01)."
|
||||
),
|
||||
"example": "# EMCAR measurement\\n0 0.5\\n45 0.8\\n90 1.0\\n135 0.8\\n180 0.5",
|
||||
},
|
||||
"nec2": {
|
||||
"name": "NEC2 Radiation Output",
|
||||
"ext": ".out / .nec",
|
||||
"tool": "import_pattern_nec2",
|
||||
"desc": (
|
||||
"NEC2/NEC4 output containing a RADIATION PATTERNS section.\n"
|
||||
"Full theta\u00d7phi grid with VERT, HOR, and TOTAL gain columns.\n"
|
||||
"Frequency is auto-detected from the file header."
|
||||
),
|
||||
"example": "(Standard NEC2 output file \u2014 run your .nec model first)",
|
||||
},
|
||||
"s1p": {
|
||||
"name": "Touchstone S1P",
|
||||
"ext": ".s1p",
|
||||
"tool": "import_pattern_s1p",
|
||||
"desc": (
|
||||
"Touchstone S-parameter file containing S11 data.\n"
|
||||
"Finds resonance, computes impedance, then generates an\n"
|
||||
"analytical pattern using the Phase 1 antenna models.\n"
|
||||
"Supports RI, MA, and DB formats."
|
||||
),
|
||||
"example": "# Hz S RI R 50\\n! NanoVNA export\\n144000000 0.1 -0.05\\n145000000 0.02 -0.01",
|
||||
},
|
||||
}
|
||||
|
||||
fmt = format_details.get(format.lower(), format_details["csv"])
|
||||
|
||||
return [
|
||||
Message(
|
||||
role="user",
|
||||
content=f"I want to import a {fmt['name']} ({fmt['ext']}) antenna pattern for 3D visualization.",
|
||||
),
|
||||
Message(
|
||||
role="assistant",
|
||||
content=f"""I'll help you import a {fmt["name"]} pattern file.
|
||||
|
||||
**Format**: {fmt["name"]} ({fmt["ext"]})
|
||||
**Tool**: `{fmt["tool"]}`
|
||||
|
||||
**Data format:**
|
||||
{fmt["desc"]}
|
||||
|
||||
**Example content:**
|
||||
```
|
||||
{fmt["example"]}
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
1. Read the file content (or paste it directly)
|
||||
2. Call `{fmt["tool"]}` with the content string
|
||||
3. The tool returns a standard pattern dict with {{theta_deg, phi_deg, gain_dbi}}
|
||||
4. If the web UI is running, upload via the "Load File" button for instant 3D rendering
|
||||
|
||||
**All supported formats** (use `list_pattern_formats` for full details):
|
||||
| Format | Extension | Data |
|
||||
|--------|-----------|------|
|
||||
| CSV | .csv | 2-col or 3-col angle/gain |
|
||||
| EMCAR | .dat | Positioner angle + amplitude |
|
||||
| NEC2 | .out, .nec | Full radiation pattern grid |
|
||||
| S1P | .s1p | S11 \u2192 analytical pattern |
|
||||
|
||||
**Tips:**
|
||||
- CSV and EMCAR accept an optional `frequency_hz` parameter for metadata
|
||||
- NEC2 supports `polarization` selection: 'total', 'vert', or 'hor'
|
||||
- S1P supports `antenna_type` override or 'auto' detection from impedance
|
||||
- Single-cut data (2-col CSV, EMCAR) is synthesized to 3D with a sin(\u03b8) taper
|
||||
|
||||
Please share the file content and I'll import it.""",
|
||||
),
|
||||
]
|
||||
|
||||
@ -314,11 +314,36 @@ class NanoVNAProtocol:
|
||||
help_text = " ".join(help_lines).lower()
|
||||
# The help output format is: "Commands: scan scan_bin data ..."
|
||||
for cmd in [
|
||||
"scan_bin", "data", "frequencies", "sweep", "power", "bandwidth",
|
||||
"cal", "save", "recall", "trace", "marker", "edelay", "s21offset",
|
||||
"capture", "vbat", "tcxo", "reset", "smooth", "transform",
|
||||
"threshold", "info", "version", "color", "measure", "pause",
|
||||
"resume", "config", "usart_cfg", "vbat_offset", "time",
|
||||
"scan_bin",
|
||||
"data",
|
||||
"frequencies",
|
||||
"sweep",
|
||||
"power",
|
||||
"bandwidth",
|
||||
"cal",
|
||||
"save",
|
||||
"recall",
|
||||
"trace",
|
||||
"marker",
|
||||
"edelay",
|
||||
"s21offset",
|
||||
"capture",
|
||||
"vbat",
|
||||
"tcxo",
|
||||
"reset",
|
||||
"smooth",
|
||||
"transform",
|
||||
"threshold",
|
||||
"info",
|
||||
"version",
|
||||
"color",
|
||||
"measure",
|
||||
"pause",
|
||||
"resume",
|
||||
"config",
|
||||
"usart_cfg",
|
||||
"vbat_offset",
|
||||
"time",
|
||||
]:
|
||||
if cmd in help_text:
|
||||
info.capabilities.append(cmd)
|
||||
@ -366,6 +391,7 @@ class NanoVNAProtocol:
|
||||
|
||||
# -- Data parsing helpers --
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanPoint:
|
||||
frequency_hz: int | None = None
|
||||
|
||||
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",
|
||||
}
|
||||
@ -12,28 +12,94 @@ from mcnanovna.nanovna import NanoVNA
|
||||
from mcnanovna.prompts import register_prompts
|
||||
|
||||
# All public methods on NanoVNA that should become MCP tools.
|
||||
# Grouped by category for maintainability.
|
||||
# Each method lives in a mixin class under tools/ — grouped here by module.
|
||||
_TOOL_METHODS = [
|
||||
# Tier 1: Essential measurement & control
|
||||
"info", "sweep", "scan", "data", "frequencies", "marker", "cal",
|
||||
"save", "recall", "pause", "resume",
|
||||
# Tier 2: Configuration
|
||||
"power", "bandwidth", "edelay", "s21offset", "vbat", "capture",
|
||||
# Tier 3: Advanced
|
||||
"trace", "transform", "smooth", "threshold", "reset", "version",
|
||||
"detect", "disconnect", "raw_command", "cw",
|
||||
# Phase 1 additions: Essential commands
|
||||
"measure", "config", "saveconfig", "clearconfig", "color", "freq",
|
||||
"tcxo", "vbat_offset",
|
||||
# Phase 1 additions: Touch / Remote Desktop
|
||||
"touchcal", "touchtest", "refresh", "touch", "release",
|
||||
# Phase 1 additions: SD Card storage
|
||||
"sd_list", "sd_read", "sd_delete", "time",
|
||||
# Phase 1 additions: Debug / Diagnostic
|
||||
"i2c", "si", "lcd", "threads", "stat", "sample", "test",
|
||||
"gain", "dump", "port", "offset", "dac", "usart_cfg", "usart", "band",
|
||||
# Convenience: server-side analysis
|
||||
# tools/measurement.py — MeasurementMixin
|
||||
"info",
|
||||
"sweep",
|
||||
"scan",
|
||||
"data",
|
||||
"frequencies",
|
||||
"marker",
|
||||
"cal",
|
||||
"save",
|
||||
"recall",
|
||||
"pause",
|
||||
"resume",
|
||||
# tools/config.py — ConfigMixin
|
||||
"power",
|
||||
"bandwidth",
|
||||
"edelay",
|
||||
"s21offset",
|
||||
"vbat",
|
||||
"capture",
|
||||
"measure",
|
||||
"config",
|
||||
"saveconfig",
|
||||
"clearconfig",
|
||||
"color",
|
||||
"freq",
|
||||
"tcxo",
|
||||
"vbat_offset",
|
||||
"threshold",
|
||||
# tools/display.py — DisplayMixin
|
||||
"trace",
|
||||
"transform",
|
||||
"smooth",
|
||||
"touchcal",
|
||||
"touchtest",
|
||||
"refresh",
|
||||
"touch",
|
||||
"release",
|
||||
# tools/device.py — DeviceMixin
|
||||
"reset",
|
||||
"version",
|
||||
"detect",
|
||||
"disconnect",
|
||||
"raw_command",
|
||||
"cw",
|
||||
"sd_list",
|
||||
"sd_read",
|
||||
"sd_delete",
|
||||
"time",
|
||||
# tools/diagnostics.py — DiagnosticsMixin
|
||||
"i2c",
|
||||
"si",
|
||||
"lcd",
|
||||
"threads",
|
||||
"stat",
|
||||
"sample",
|
||||
"test",
|
||||
"gain",
|
||||
"dump",
|
||||
"port",
|
||||
"offset",
|
||||
"dac",
|
||||
"usart_cfg",
|
||||
"usart",
|
||||
"band",
|
||||
# tools/analysis.py — AnalysisMixin
|
||||
"export_touchstone",
|
||||
"export_csv",
|
||||
"analyze_filter",
|
||||
"analyze_xtal",
|
||||
"analyze_tdr",
|
||||
"analyze_component",
|
||||
"analyze_lc_series",
|
||||
"analyze_lc_shunt",
|
||||
"analyze_lc_match",
|
||||
"analyze_s11_resonance",
|
||||
"analyze",
|
||||
# tools/radiation.py — RadiationMixin
|
||||
"radiation_pattern",
|
||||
"radiation_pattern_from_data",
|
||||
"radiation_pattern_multi",
|
||||
# tools/pattern_import.py — PatternImportMixin
|
||||
"import_pattern_csv",
|
||||
"import_pattern_emcar",
|
||||
"import_pattern_nec2",
|
||||
"import_pattern_s1p",
|
||||
"list_pattern_formats",
|
||||
]
|
||||
|
||||
|
||||
@ -47,8 +113,31 @@ def create_server() -> FastMCP:
|
||||
"on first tool call — just plug in and go.\n\n"
|
||||
"Use the 'analyze' tool for comprehensive measurement reports with SWR, "
|
||||
"impedance, bandwidth, and reactive component analysis built in.\n\n"
|
||||
"Export tools: 'export_touchstone' (.s1p/.s2p) and 'export_csv' for data "
|
||||
"interchange with other RF tools.\n\n"
|
||||
"Specialized analysis: 'analyze_filter' (type classification, cutoffs, Q), "
|
||||
"'analyze_xtal' (crystal motional parameters), 'analyze_tdr' (time-domain "
|
||||
"reflectometry with impedance/distance profiling).\n\n"
|
||||
"LC & impedance tools: 'analyze_component' (identify unknown L/C/R from S11), "
|
||||
"'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_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"
|
||||
"Pattern import tools: 'import_pattern_csv', 'import_pattern_emcar', "
|
||||
"'import_pattern_nec2', 'import_pattern_s1p' — import measured/simulated patterns "
|
||||
"from external files (CSV, EMCAR vna.dat, NEC2 output, Touchstone S1P). "
|
||||
"Use 'list_pattern_formats' for format details and examples.\n\n"
|
||||
"For automated antenna range measurement with an ESP32 positioner, use "
|
||||
"mcpositioner (separate MCP server) to control the positioner hardware, then "
|
||||
"coordinate both servers — see the measure_antenna_range prompt for guidance.\n\n"
|
||||
"Prompts are available for guided workflows: calibrate, export_touchstone, "
|
||||
"analyze_antenna, measure_cable, and compare_sweeps."
|
||||
"analyze_antenna, measure_cable, compare_sweeps, analyze_crystal, "
|
||||
"analyze_filter_response, measure_tdr, measure_component, measure_lc_series, "
|
||||
"measure_lc_shunt, impedance_match, visualize_radiation_pattern, import_pattern, "
|
||||
"and measure_antenna_range."
|
||||
),
|
||||
)
|
||||
vna = NanoVNA()
|
||||
@ -64,6 +153,8 @@ def create_server() -> FastMCP:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import os
|
||||
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
@ -73,6 +164,19 @@ def main() -> None:
|
||||
|
||||
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.run()
|
||||
|
||||
|
||||
37
src/mcnanovna/tools/__init__.py
Normal file
37
src/mcnanovna/tools/__init__.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""NanoVNA tool mixins — each groups related MCP tool methods.
|
||||
|
||||
The NanoVNA class composes all mixins, so server.py's getattr() registration
|
||||
loop works unchanged. Each mixin accesses shared state (self._protocol,
|
||||
self._ensure_connected, etc.) through the final composed class at runtime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
from .analysis import AnalysisMixin
|
||||
from .config import ConfigMixin
|
||||
from .device import DeviceMixin
|
||||
from .diagnostics import DiagnosticsMixin
|
||||
from .display import DisplayMixin
|
||||
from .measurement import MeasurementMixin
|
||||
from .pattern_import import PatternImportMixin
|
||||
from .radiation import RadiationMixin
|
||||
|
||||
|
||||
async def _progress(ctx: Context | None, progress: float, total: float, message: str) -> None:
|
||||
"""Report progress if Context is available."""
|
||||
if ctx:
|
||||
await ctx.report_progress(progress, total, message)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AnalysisMixin",
|
||||
"ConfigMixin",
|
||||
"DeviceMixin",
|
||||
"DiagnosticsMixin",
|
||||
"DisplayMixin",
|
||||
"MeasurementMixin",
|
||||
"PatternImportMixin",
|
||||
"RadiationMixin",
|
||||
]
|
||||
574
src/mcnanovna/tools/analysis.py
Normal file
574
src/mcnanovna/tools/analysis.py
Normal file
@ -0,0 +1,574 @@
|
||||
"""AnalysisMixin — export, filter analysis, crystal analysis, TDR, component ID, LC matching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
|
||||
class AnalysisMixin:
|
||||
"""Analysis and export tools: analyze, export_touchstone, export_csv, analyze_filter, analyze_xtal,
|
||||
analyze_tdr, analyze_component, analyze_lc_series, analyze_lc_shunt, analyze_lc_match, analyze_s11_resonance.
|
||||
"""
|
||||
|
||||
async def export_touchstone(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
format: str = "s1p",
|
||||
z0: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Export S-parameter data as Touchstone file content (.s1p or .s2p).
|
||||
|
||||
Runs a scan and formats results per IEEE Std 1363. The .s1p format
|
||||
captures S11 reflection only; .s2p captures both S11 and S21
|
||||
(S12/S22 are zeroed since the NanoVNA doesn't measure them).
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
format: Output format — 's1p' (S11 only) or 's2p' (S11 + S21)
|
||||
z0: Reference impedance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import format_touchstone_s1p, format_touchstone_s2p
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
s2p = format.lower() == "s2p"
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning {points} points...")
|
||||
scan_result = await self.scan(
|
||||
start_hz,
|
||||
stop_hz,
|
||||
points,
|
||||
s11=True,
|
||||
s21=s2p,
|
||||
)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Formatting Touchstone data...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||
|
||||
if s2p:
|
||||
s21_data = [pt["s21"] for pt in scan_result["data"]]
|
||||
content = format_touchstone_s2p(freqs, s11_data, s21_data, z0)
|
||||
filename = f"nanovna_{start_hz}_{stop_hz}.s2p"
|
||||
else:
|
||||
content = format_touchstone_s1p(freqs, s11_data, z0)
|
||||
filename = f"nanovna_{start_hz}_{stop_hz}.s1p"
|
||||
|
||||
await _progress(ctx, 4, 4, "Export complete")
|
||||
return {
|
||||
"content": content,
|
||||
"filename": filename,
|
||||
"format": "s2p" if s2p else "s1p",
|
||||
"points": scan_result["points"],
|
||||
"z0": z0,
|
||||
}
|
||||
|
||||
async def export_csv(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
s11: bool = True,
|
||||
s21: bool = True,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Export scan data as CSV with derived metrics.
|
||||
|
||||
Runs a scan and formats results as CSV including raw S-parameters
|
||||
and derived values (SWR, return loss, impedance, insertion loss, phase).
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
s11: Include S11 reflection data (default True)
|
||||
s21: Include S21 transmission data (default True)
|
||||
"""
|
||||
from mcnanovna.calculations import format_csv
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning {points} points...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=s11, s21=s21)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Formatting CSV data...")
|
||||
content = format_csv(scan_result["data"])
|
||||
|
||||
await _progress(ctx, 4, 4, "Export complete")
|
||||
return {
|
||||
"content": content,
|
||||
"filename": f"nanovna_{start_hz}_{stop_hz}.csv",
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
async def analyze_filter(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Classify and characterize a filter from S21 transmission measurement.
|
||||
|
||||
Scans S21 through the DUT and determines filter type (lowpass, highpass,
|
||||
bandpass), cutoff frequencies at -3/-6/-10/-20 dB, bandwidth, Q factor,
|
||||
and roll-off rate. Connect the filter between Port 1 and Port 2.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
"""
|
||||
from mcnanovna.calculations import analyze_filter_response
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Analyzing filter response...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s21_data = [pt["s21"] for pt in scan_result["data"]]
|
||||
result = analyze_filter_response(s21_data, freqs)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "Filter analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_xtal(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
z0: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Extract quartz crystal motional parameters from S21 measurement.
|
||||
|
||||
Scans S21 through a crystal in a series test fixture and determines
|
||||
series/parallel resonance, motional resistance (Rm), inductance (Lm),
|
||||
capacitance (Cm), holder capacitance (Cp), Q factor, and insertion loss.
|
||||
Connect the crystal between Port 1 and Port 2.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
z0: Reference impedance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import analyze_crystal
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Extracting crystal parameters...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s21_data = [pt["s21"] for pt in scan_result["data"]]
|
||||
result = analyze_crystal(s21_data, freqs, z0)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "Crystal analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_tdr(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
velocity_factor: float = 0.66,
|
||||
window: str = "normal",
|
||||
z0: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Time Domain Reflectometry analysis from S11 frequency sweep.
|
||||
|
||||
Scans S11 reflection data and transforms it to the time domain using
|
||||
an inverse FFT with Kaiser windowing. Returns impedance and reflection
|
||||
profiles along the cable/transmission line, plus detected discontinuities.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz (use wide span for better resolution)
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
velocity_factor: Cable velocity factor (default 0.66 for RG-58)
|
||||
window: Kaiser window — 'minimum' (sharp), 'normal', or 'maximum' (smooth)
|
||||
z0: Reference impedance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import tdr_analysis
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, 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, 4, "Computing TDR transform...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||
result = tdr_analysis(s11_data, freqs, velocity_factor, window, z0)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "TDR analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_component(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
z0: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Identify an unknown component from S11 reflection measurement.
|
||||
|
||||
Scans S11 and classifies the DUT as an inductor, capacitor, resistor,
|
||||
or LC circuit. Reports the primary value (nH, pF, or ohm), ESR,
|
||||
Q factor, and self-resonant frequency if present.
|
||||
Connect the component to Port 1 (open end of the test fixture).
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
z0: Reference impedance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import classify_component
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, 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, 4, "Classifying component...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||
result = classify_component(s11_data, freqs, z0)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "Component analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_lc_series(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
measure_r: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Measure a series LC resonator from S21 transmission data.
|
||||
|
||||
The component under test is placed in series between Port 1 and Port 2.
|
||||
At resonance, the series LC circuit has minimum impedance, producing a
|
||||
transmission peak in S21. Reports resonant frequency, motional resistance,
|
||||
inductance, capacitance, Q factor, bandwidth, and insertion loss.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
measure_r: Port termination resistance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import analyze_lc_series as calc_lc_series
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Analyzing series LC resonator...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s21_data = [pt["s21"] for pt in scan_result["data"]]
|
||||
result = calc_lc_series(s21_data, freqs, measure_r)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "Series LC analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_lc_shunt(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
measure_r: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Measure a shunt LC resonator from S21 transmission data.
|
||||
|
||||
The component under test is connected as a shunt (parallel to ground)
|
||||
between Port 1 and Port 2. At resonance, the shunt LC circuit has
|
||||
maximum impedance to ground, producing a transmission dip (absorption)
|
||||
in S21. Reports resonant frequency, motional resistance, inductance,
|
||||
capacitance, Q factor, bandwidth, and insertion loss.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
measure_r: Port termination resistance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import analyze_lc_shunt as calc_lc_shunt
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, f"Scanning S21 ({points} points)...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=False, s21=True)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 4, "Analyzing shunt LC resonator...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s21_data = [pt["s21"] for pt in scan_result["data"]]
|
||||
result = calc_lc_shunt(s21_data, freqs, measure_r)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, "Shunt LC analysis complete")
|
||||
return result
|
||||
|
||||
async def analyze_lc_match(
|
||||
self,
|
||||
frequency_hz: int,
|
||||
r: float | None = None,
|
||||
x: float | None = None,
|
||||
z0: float = 50.0,
|
||||
start_hz: int | None = None,
|
||||
stop_hz: int | None = None,
|
||||
points: int = 101,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Compute L-network impedance matching solutions.
|
||||
|
||||
Accepts either a direct impedance (r + jx) or scans S11 to measure it.
|
||||
|
||||
**Direct mode**: Provide r and x (real and imaginary parts of impedance
|
||||
in ohms) along with frequency_hz. No hardware needed.
|
||||
|
||||
**Scan mode**: Provide start_hz and stop_hz to scan S11. The impedance
|
||||
at the frequency nearest to frequency_hz is extracted and used for
|
||||
matching. Connect the load to Port 1.
|
||||
|
||||
Returns up to 4 L-network solutions, each specifying source shunt,
|
||||
series, and load shunt components (inductors or capacitors with values).
|
||||
|
||||
Args:
|
||||
frequency_hz: Design frequency in Hz for component value calculation
|
||||
r: Load resistance in ohms (direct mode)
|
||||
x: Load reactance in ohms (direct mode)
|
||||
z0: Target impedance in ohms (default 50)
|
||||
start_hz: Start frequency for S11 scan (scan mode)
|
||||
stop_hz: Stop frequency for S11 scan (scan mode)
|
||||
points: Number of measurement points for scan (default 101)
|
||||
"""
|
||||
from mcnanovna.calculations import lc_match
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
scan_info = None
|
||||
|
||||
if r is not None and x is not None:
|
||||
# Direct mode — pure math, no hardware needed
|
||||
total_steps = 2
|
||||
await _progress(ctx, 1, total_steps, f"Computing match for {r}+j{x} \u03a9 at {frequency_hz} Hz...")
|
||||
elif start_hz is not None and stop_hz is not None:
|
||||
# Scan mode — measure impedance from S11
|
||||
total_steps = 4
|
||||
await _progress(ctx, 1, total_steps, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, total_steps, 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
|
||||
|
||||
scan_info = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
# Find the data point nearest to the target frequency
|
||||
data = scan_result["data"]
|
||||
best_idx = 0
|
||||
best_dist = abs(data[0]["frequency_hz"] - frequency_hz)
|
||||
for i, pt in enumerate(data[1:], 1):
|
||||
dist = abs(pt["frequency_hz"] - frequency_hz)
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
best_idx = i
|
||||
|
||||
# Convert S11 to impedance: Z = z0 * (1 + \u0393) / (1 - \u0393)
|
||||
s11 = data[best_idx]["s11"]
|
||||
gamma = complex(s11["real"], s11["imag"])
|
||||
denom = 1.0 - gamma
|
||||
if abs(denom) < 1e-12:
|
||||
return {"error": "S11 \u2248 1.0 (open circuit), cannot compute impedance"}
|
||||
z = z0 * (1.0 + gamma) / denom
|
||||
r = z.real
|
||||
x = z.imag
|
||||
frequency_hz = data[best_idx]["frequency_hz"]
|
||||
|
||||
await _progress(ctx, 3, total_steps, f"Impedance at {frequency_hz} Hz: {r:.1f}+j{x:.1f} \u03a9")
|
||||
else:
|
||||
return {"error": "Provide either (r, x) for direct mode or (start_hz, stop_hz) for scan mode"}
|
||||
|
||||
result = lc_match(r, x, frequency_hz, z0)
|
||||
if scan_info is not None:
|
||||
result["scan_info"] = scan_info
|
||||
|
||||
await _progress(ctx, total_steps, total_steps, "Matching network computation complete")
|
||||
return result
|
||||
|
||||
async def analyze_s11_resonance(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 201,
|
||||
z0: float = 50.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Find resonant frequencies from S11 reflection data.
|
||||
|
||||
Scans S11 and searches for up to 6 points where the reactance crosses
|
||||
zero, indicating resonance. Each resonance is classified as series
|
||||
(reactance goes from negative to positive) or parallel (positive to
|
||||
negative). Useful for identifying resonant modes of antennas, filters,
|
||||
or transmission line stubs.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 201)
|
||||
z0: Reference impedance in ohms (default 50)
|
||||
"""
|
||||
from mcnanovna.calculations import find_s11_resonances
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 4, 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, 4, "Searching for resonances...")
|
||||
freqs = [pt["frequency_hz"] for pt in scan_result["data"]]
|
||||
s11_data = [pt["s11"] for pt in scan_result["data"]]
|
||||
result = find_s11_resonances(s11_data, freqs, z0)
|
||||
result["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
}
|
||||
|
||||
await _progress(ctx, 4, 4, f"Found {result['count']} resonance(s)")
|
||||
return result
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
s11: bool = True,
|
||||
s21: bool = False,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Run a scan and return comprehensive S-parameter analysis.
|
||||
|
||||
Combines the scan tool with server-side calculations to produce
|
||||
a full measurement report including SWR, impedance, bandwidth,
|
||||
return loss, and reactive components — without the LLM needing
|
||||
to do the math.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (default 101)
|
||||
s11: Include S11 reflection analysis (default True)
|
||||
s21: Include S21 transmission analysis (default False)
|
||||
"""
|
||||
from mcnanovna.calculations import analyze_scan
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 5, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 5, f"Scanning {points} points from {start_hz} to {stop_hz} Hz...")
|
||||
scan_result = await self.scan(start_hz, stop_hz, points, s11=s11, s21=s21)
|
||||
if "error" in scan_result:
|
||||
return scan_result
|
||||
|
||||
await _progress(ctx, 3, 5, f"Received {scan_result['points']} measurement points")
|
||||
|
||||
await _progress(ctx, 4, 5, "Calculating S-parameter metrics...")
|
||||
analysis = analyze_scan(scan_result["data"])
|
||||
analysis["scan_info"] = {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": scan_result["points"],
|
||||
"binary": scan_result.get("binary", False),
|
||||
}
|
||||
|
||||
await _progress(ctx, 5, 5, "Analysis complete")
|
||||
return analysis
|
||||
417
src/mcnanovna/tools/config.py
Normal file
417
src/mcnanovna/tools/config.py
Normal file
@ -0,0 +1,417 @@
|
||||
"""ConfigMixin — device configuration, power, bandwidth, capture, and settings tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import re
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
# Si5351 drive level descriptions
|
||||
POWER_DESCRIPTIONS = {
|
||||
0: "2mA Si5351 drive",
|
||||
1: "4mA Si5351 drive",
|
||||
2: "6mA Si5351 drive",
|
||||
3: "8mA Si5351 drive",
|
||||
255: "auto",
|
||||
}
|
||||
|
||||
|
||||
class ConfigMixin:
|
||||
"""Configuration tools: power, bandwidth, edelay, capture, measure, config, color, tcxo, etc."""
|
||||
|
||||
def power(self, level: int | None = None) -> dict:
|
||||
"""Get or set RF output power level.
|
||||
|
||||
Args:
|
||||
level: Power level (0=2mA, 1=4mA, 2=6mA, 3=8mA Si5351 drive, 255=auto)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if level is not None:
|
||||
self._protocol.send_text_command(f"power {level}")
|
||||
|
||||
lines = self._protocol.send_text_command("power")
|
||||
# Response: "power: N"
|
||||
for line in lines:
|
||||
if "power" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
val = int(parts[1].strip())
|
||||
return {
|
||||
"power": val,
|
||||
"description": POWER_DESCRIPTIONS.get(val, f"level {val}"),
|
||||
}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"power": level if level is not None else -1, "description": "unknown", "raw": lines}
|
||||
|
||||
def bandwidth(self, bw_hz: int | None = None) -> dict:
|
||||
"""Get or set the IF bandwidth (affects measurement speed vs noise floor).
|
||||
|
||||
Args:
|
||||
bw_hz: Bandwidth in Hz, or bandwidth divider value. Lower = slower but more accurate.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("bandwidth"):
|
||||
return {"error": "bandwidth command not supported by this firmware"}
|
||||
|
||||
if bw_hz is not None:
|
||||
self._protocol.send_text_command(f"bandwidth {bw_hz}")
|
||||
|
||||
lines = self._protocol.send_text_command("bandwidth")
|
||||
# Response: "bandwidth N (Mhz)" or similar
|
||||
if lines:
|
||||
line = lines[0].strip()
|
||||
# Try to parse "bandwidth <divider> (<freq>Hz)"
|
||||
m = re.match(r"bandwidth\s+(\d+)\s*\((\d+)\s*Hz\)", line, re.IGNORECASE)
|
||||
if m:
|
||||
return {"bandwidth_divider": int(m.group(1)), "bandwidth_hz": int(m.group(2))}
|
||||
# Fallback: just return raw
|
||||
return {"raw": line}
|
||||
return {"bandwidth_divider": 0, "bandwidth_hz": 0}
|
||||
|
||||
def edelay(self, seconds: float | None = None) -> dict:
|
||||
"""Get or set electrical delay compensation in seconds.
|
||||
|
||||
Args:
|
||||
seconds: Electrical delay in seconds (e.g. 1e-9 for 1 nanosecond)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if seconds is not None:
|
||||
self._protocol.send_text_command(f"edelay {seconds}")
|
||||
|
||||
lines = self._protocol.send_text_command("edelay")
|
||||
if lines:
|
||||
try:
|
||||
return {"edelay_seconds": float(lines[0].strip())}
|
||||
except ValueError:
|
||||
return {"raw": lines}
|
||||
return {"edelay_seconds": 0.0}
|
||||
|
||||
def s21offset(self, db: float | None = None) -> dict:
|
||||
"""Get or set S21 offset correction in dB.
|
||||
|
||||
Args:
|
||||
db: Offset value in dB
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("s21offset"):
|
||||
return {"error": "s21offset command not supported by this firmware"}
|
||||
|
||||
if db is not None:
|
||||
self._protocol.send_text_command(f"s21offset {db}")
|
||||
|
||||
lines = self._protocol.send_text_command("s21offset")
|
||||
if lines:
|
||||
try:
|
||||
return {"s21_offset_db": float(lines[0].strip())}
|
||||
except ValueError:
|
||||
return {"raw": lines}
|
||||
return {"s21_offset_db": 0.0}
|
||||
|
||||
def vbat(self) -> dict:
|
||||
"""Read battery voltage in millivolts."""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("vbat")
|
||||
# Response: "4151 mV"
|
||||
if lines:
|
||||
parts = lines[0].strip().split()
|
||||
if parts:
|
||||
try:
|
||||
mv = int(parts[0])
|
||||
return {"voltage_mv": mv, "voltage_v": round(mv / 1000.0, 3)}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"voltage_mv": 0, "voltage_v": 0.0, "raw": lines}
|
||||
|
||||
def _capture_raw_bytes(self) -> tuple[int, int, bytearray]:
|
||||
"""Read raw RGB565 pixel data from the device. Blocking serial I/O."""
|
||||
import time
|
||||
|
||||
di = self._protocol.device_info
|
||||
width = di.lcd_width
|
||||
height = di.lcd_height
|
||||
expected_size = width * height * 2
|
||||
|
||||
self._protocol._drain()
|
||||
self._protocol._send_command("capture")
|
||||
|
||||
ser = self._protocol._require_connection()
|
||||
old_timeout = ser.timeout
|
||||
ser.timeout = 10.0
|
||||
try:
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 10.0
|
||||
|
||||
# Read past echo line
|
||||
while time.monotonic() < deadline:
|
||||
chunk = ser.read(max(1, ser.in_waiting))
|
||||
if chunk:
|
||||
buf += chunk
|
||||
if b"\r\n" in buf:
|
||||
break
|
||||
|
||||
echo_end = buf.index(b"\r\n") + 2
|
||||
pixel_buf = buf[echo_end:]
|
||||
|
||||
# Read pixel data
|
||||
while len(pixel_buf) < expected_size and time.monotonic() < deadline:
|
||||
remaining = expected_size - len(pixel_buf)
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if chunk:
|
||||
pixel_buf += chunk
|
||||
|
||||
# Byte-swap RGB565 (firmware sends native LE, display expects BE)
|
||||
swapped = bytearray(expected_size)
|
||||
for i in range(0, min(len(pixel_buf), expected_size), 2):
|
||||
if i + 1 < len(pixel_buf):
|
||||
swapped[i] = pixel_buf[i + 1]
|
||||
swapped[i + 1] = pixel_buf[i]
|
||||
|
||||
# Drain trailing prompt
|
||||
trailing = b""
|
||||
while time.monotonic() < deadline:
|
||||
chunk = ser.read(max(1, ser.in_waiting))
|
||||
if chunk:
|
||||
trailing += chunk
|
||||
if b"ch> " in trailing or not chunk:
|
||||
break
|
||||
|
||||
return width, height, swapped
|
||||
finally:
|
||||
ser.timeout = old_timeout
|
||||
|
||||
async def capture(self, raw: bool = False, ctx: Context | None = None):
|
||||
"""Capture the current LCD screen as RGB565 pixel data (base64 encoded).
|
||||
|
||||
Returns width, height, and raw pixel data for rendering. The pixel format
|
||||
is RGB565 (16-bit, 2 bytes per pixel). Total size = width * height * 2 bytes.
|
||||
|
||||
Args:
|
||||
raw: If True, return raw RGB565 data as a dict with base64-encoded bytes.
|
||||
If False (default), convert to PNG and return as an Image.
|
||||
"""
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 3, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
await _progress(ctx, 2, 3, "Reading LCD pixel data...")
|
||||
width, height, swapped = await asyncio.to_thread(self._capture_raw_bytes)
|
||||
|
||||
if raw:
|
||||
await _progress(ctx, 3, 3, "Capture complete")
|
||||
return {
|
||||
"format": "rgb565",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"data_length": len(swapped),
|
||||
"data_base64": base64.b64encode(bytes(swapped)).decode("ascii"),
|
||||
}
|
||||
|
||||
await _progress(ctx, 3, 3, "Encoding PNG image...")
|
||||
|
||||
# Convert RGB565 to PNG and return as MCP Image
|
||||
import io
|
||||
import struct as _struct
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
img = PILImage.new("RGB", (width, height))
|
||||
pixels = img.load()
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
offset = (y * width + x) * 2
|
||||
pixel = _struct.unpack(">H", swapped[offset : offset + 2])[0]
|
||||
r = ((pixel >> 11) & 0x1F) << 3
|
||||
g = ((pixel >> 5) & 0x3F) << 2
|
||||
b = (pixel & 0x1F) << 3
|
||||
pixels[x, y] = (r, g, b)
|
||||
|
||||
buf_png = io.BytesIO()
|
||||
img.save(buf_png, format="PNG")
|
||||
return Image(data=buf_png.getvalue(), format="png")
|
||||
|
||||
def measure(self, mode: str | None = None) -> dict:
|
||||
"""Set on-device measurement display mode.
|
||||
|
||||
Controls what the NanoVNA computes and displays on-screen. Available
|
||||
modes depend on firmware build flags.
|
||||
|
||||
Args:
|
||||
mode: Measurement mode — one of: none, lc, lcshunt, lcseries,
|
||||
xtal, filter, cable, resonance. Omit to get usage help.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("measure"):
|
||||
return {"error": "measure command not supported by this firmware"}
|
||||
if mode is not None:
|
||||
lines = self._protocol.send_text_command(f"measure {mode}")
|
||||
return {"mode": mode, "response": lines}
|
||||
lines = self._protocol.send_text_command("measure")
|
||||
return {"response": lines}
|
||||
|
||||
def config(self, option: str | None = None, value: int | None = None) -> dict:
|
||||
"""Query or set device configuration options.
|
||||
|
||||
Options depend on firmware build (e.g., auto, avg, connection, mode,
|
||||
grid, dot, bk, flip, separator, tif). Each takes a value of 0 or 1.
|
||||
|
||||
Args:
|
||||
option: Configuration option name
|
||||
value: Value to set (0 or 1)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("config"):
|
||||
return {"error": "config command not supported by this firmware"}
|
||||
if option is not None and value is not None:
|
||||
lines = self._protocol.send_text_command(f"config {option} {value}")
|
||||
return {"option": option, "value": value, "response": lines}
|
||||
lines = self._protocol.send_text_command("config")
|
||||
return {"response": lines}
|
||||
|
||||
def saveconfig(self) -> dict:
|
||||
"""Save current device configuration to flash memory.
|
||||
|
||||
Saves config_t (distinct from calibration save which uses slots).
|
||||
This includes touch calibration, display settings, TCXO frequency, etc.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("saveconfig")
|
||||
return {"saved": True, "response": lines}
|
||||
|
||||
def clearconfig(self, key: str = "1234") -> dict:
|
||||
"""Clear all stored configuration and calibration data from flash.
|
||||
|
||||
This is a destructive operation — requires the protection key '1234'.
|
||||
After clearing, you must recalibrate the device.
|
||||
|
||||
Args:
|
||||
key: Protection key (must be '1234' to confirm)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if key != "1234":
|
||||
return {"error": "Protection key must be '1234' to confirm clearing all data"}
|
||||
lines = self._protocol.send_text_command(f"clearconfig {key}")
|
||||
return {"cleared": True, "response": lines}
|
||||
|
||||
def color(self, color_id: int | None = None, rgb24: int | None = None) -> dict:
|
||||
"""Get or set display color palette entries.
|
||||
|
||||
With no args, lists all color slots and their current RGB values.
|
||||
With id + rgb24, sets a specific color.
|
||||
|
||||
Args:
|
||||
color_id: Palette slot index (0-31)
|
||||
rgb24: 24-bit RGB color value (e.g., 0xFF0000 for red)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("color"):
|
||||
return {"error": "color command not supported by this firmware"}
|
||||
if color_id is not None and rgb24 is not None:
|
||||
self._protocol.send_text_command(f"color {color_id} {rgb24}")
|
||||
return {"color_id": color_id, "rgb24": f"0x{rgb24:06x}", "set": True}
|
||||
lines = self._protocol.send_text_command("color")
|
||||
colors = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if ":" in line:
|
||||
parts = line.split(":")
|
||||
try:
|
||||
idx = int(parts[0].strip())
|
||||
val = parts[1].strip()
|
||||
colors.append({"id": idx, "rgb24": val})
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return {"colors": colors, "raw": lines}
|
||||
|
||||
def freq(self, frequency_hz: int) -> dict:
|
||||
"""Set a single output frequency and pause the sweep.
|
||||
|
||||
Useful for CW measurements or signal generation at a specific frequency.
|
||||
The sweep is paused automatically.
|
||||
|
||||
Args:
|
||||
frequency_hz: Output frequency in Hz
|
||||
"""
|
||||
self._ensure_connected()
|
||||
self._protocol.send_text_command(f"freq {frequency_hz}")
|
||||
return {"frequency_hz": frequency_hz, "sweep": "paused"}
|
||||
|
||||
def tcxo(self, frequency_hz: int | None = None) -> dict:
|
||||
"""Get or set the TCXO reference oscillator frequency.
|
||||
|
||||
The TCXO frequency affects all frequency accuracy. Default is typically
|
||||
26000000 Hz. Adjust if you have a precision frequency reference.
|
||||
|
||||
Args:
|
||||
frequency_hz: TCXO frequency in Hz (e.g., 26000000)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("tcxo"):
|
||||
return {"error": "tcxo command not supported by this firmware"}
|
||||
if frequency_hz is not None:
|
||||
self._protocol.send_text_command(f"tcxo {frequency_hz}")
|
||||
return {"tcxo_hz": frequency_hz, "set": True}
|
||||
# Query mode returns "current: <value>"
|
||||
lines = self._protocol.send_text_command("tcxo")
|
||||
for line in lines:
|
||||
if "current:" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return {"tcxo_hz": int(parts[1].strip())}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
|
||||
def vbat_offset(self, offset: int | None = None) -> dict:
|
||||
"""Get or set battery voltage measurement offset.
|
||||
|
||||
Calibrates the battery voltage reading. The offset is added to the
|
||||
raw ADC value to compensate for hardware variations.
|
||||
|
||||
Args:
|
||||
offset: Voltage offset value (raw ADC units)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("vbat_offset"):
|
||||
return {"error": "vbat_offset command not supported by this firmware"}
|
||||
if offset is not None:
|
||||
self._protocol.send_text_command(f"vbat_offset {offset}")
|
||||
return {"offset": offset, "set": True}
|
||||
lines = self._protocol.send_text_command("vbat_offset")
|
||||
if lines:
|
||||
try:
|
||||
return {"offset": int(lines[0].strip())}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
|
||||
def threshold(self, frequency_hz: int | None = None) -> dict:
|
||||
"""Get or set the harmonic mode frequency threshold (~290 MHz default).
|
||||
|
||||
Above this frequency the Si5351 uses odd harmonics for output.
|
||||
|
||||
Args:
|
||||
frequency_hz: Threshold frequency in Hz
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if frequency_hz is not None:
|
||||
self._protocol.send_text_command(f"threshold {frequency_hz}")
|
||||
|
||||
lines = self._protocol.send_text_command("threshold")
|
||||
# Parse "current: 290000000" from response
|
||||
for line in lines:
|
||||
if "current:" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return {"threshold_hz": int(parts[1].strip())}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
237
src/mcnanovna/tools/device.py
Normal file
237
src/mcnanovna/tools/device.py
Normal file
@ -0,0 +1,237 @@
|
||||
"""DeviceMixin — device lifecycle, reset, version, SD card, CW mode, and raw commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
from mcnanovna.discovery import find_nanovna_ports
|
||||
from mcnanovna.protocol import NanoVNAProtocolError
|
||||
|
||||
|
||||
class DeviceMixin:
|
||||
"""Device tools: reset, version, detect, disconnect, raw_command, cw, sd_list, sd_read, sd_delete, time."""
|
||||
|
||||
def reset(self, dfu: bool = False) -> dict:
|
||||
"""Reset the NanoVNA device. With dfu=True, enters DFU bootloader for firmware update.
|
||||
|
||||
Args:
|
||||
dfu: If True, enter DFU bootloader mode (device will disconnect)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
cmd = "reset dfu" if dfu else "reset"
|
||||
try:
|
||||
self._protocol.send_text_command(cmd, timeout=2.0)
|
||||
except NanoVNAProtocolError:
|
||||
pass # Device resets and disconnects
|
||||
self._protocol.close()
|
||||
note = "Device entering DFU mode — reconnect after firmware update" if dfu else "Device resetting"
|
||||
return {"reset": True, "dfu": dfu, "note": note}
|
||||
|
||||
def version(self) -> dict:
|
||||
"""Get firmware version string."""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("version")
|
||||
return {"version": lines[0].strip() if lines else "unknown"}
|
||||
|
||||
def detect(self) -> dict:
|
||||
"""Scan USB ports for connected NanoVNA devices."""
|
||||
ports = find_nanovna_ports()
|
||||
return {
|
||||
"devices": [
|
||||
{
|
||||
"port": p.device,
|
||||
"vid": f"0x{p.vid:04x}",
|
||||
"pid": f"0x{p.pid:04x}",
|
||||
"serial_number": p.serial_number,
|
||||
"description": p.description,
|
||||
}
|
||||
for p in ports
|
||||
],
|
||||
"count": len(ports),
|
||||
"currently_connected": self._port,
|
||||
}
|
||||
|
||||
def disconnect(self) -> dict:
|
||||
"""Close the serial connection to the NanoVNA."""
|
||||
port = self._port
|
||||
self._protocol.close()
|
||||
self._port = None
|
||||
return {"disconnected": True, "port": port}
|
||||
|
||||
def raw_command(self, command: str) -> dict:
|
||||
"""Send an arbitrary shell command to the NanoVNA and return raw text response.
|
||||
|
||||
Escape hatch for firmware commands not wrapped as dedicated tools.
|
||||
|
||||
Args:
|
||||
command: The shell command string to send (e.g. 'config', 'usart_cfg')
|
||||
"""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command(command, timeout=10.0)
|
||||
return {"command": command, "response_lines": lines}
|
||||
|
||||
def cw(self, frequency_hz: int, power: int | None = None) -> dict:
|
||||
"""Set continuous wave (CW) mode — output a single frequency.
|
||||
|
||||
Configures the NanoVNA to sweep a single point, effectively
|
||||
becoming a CW signal generator at the specified frequency.
|
||||
|
||||
Args:
|
||||
frequency_hz: Output frequency in Hz
|
||||
power: Optional power level (0-3, or 255 for auto)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if power is not None:
|
||||
self._protocol.send_text_command(f"power {power}")
|
||||
# CW mode is just a sweep with start == stop and 1 point
|
||||
self._protocol.send_text_command(f"sweep {frequency_hz} {frequency_hz} 1")
|
||||
self._protocol.send_text_command("resume")
|
||||
return {"frequency_hz": frequency_hz, "power": power, "mode": "cw"}
|
||||
|
||||
def sd_list(self, pattern: str = "*.*") -> dict:
|
||||
"""List files on the SD card.
|
||||
|
||||
Requires SD card hardware support. Returns filenames and sizes.
|
||||
|
||||
Args:
|
||||
pattern: Glob pattern to filter files (default: '*.*')
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("sd_list"):
|
||||
return {"error": "SD card commands not supported by this firmware/hardware"}
|
||||
lines = self._protocol.send_text_command(f"sd_list {pattern}", timeout=10.0)
|
||||
files = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or "err:" in line.lower():
|
||||
if "err:" in line.lower():
|
||||
return {"error": line}
|
||||
continue
|
||||
parts = line.rsplit(" ", 1)
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
files.append({"name": parts[0], "size": int(parts[1])})
|
||||
except ValueError:
|
||||
files.append({"name": line, "size": 0})
|
||||
else:
|
||||
files.append({"name": line, "size": 0})
|
||||
return {"files": files, "count": len(files)}
|
||||
|
||||
def sd_read(self, filename: str) -> dict:
|
||||
"""Read a file from the SD card.
|
||||
|
||||
Returns the file content as base64-encoded data. The firmware sends
|
||||
a 4-byte size header followed by raw file data.
|
||||
|
||||
Args:
|
||||
filename: Name of the file to read
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("sd_read"):
|
||||
return {"error": "SD card commands not supported by this firmware/hardware"}
|
||||
|
||||
import struct
|
||||
import time
|
||||
|
||||
self._protocol._drain()
|
||||
self._protocol._send_command(f"sd_read {filename}")
|
||||
|
||||
ser = self._protocol._require_connection()
|
||||
old_timeout = ser.timeout
|
||||
ser.timeout = 10.0
|
||||
try:
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 10.0
|
||||
|
||||
# Read past echo line
|
||||
while time.monotonic() < deadline:
|
||||
chunk = ser.read(max(1, ser.in_waiting))
|
||||
if chunk:
|
||||
buf += chunk
|
||||
if b"\r\n" in buf:
|
||||
break
|
||||
|
||||
# Check for error response
|
||||
content = buf.decode("ascii", errors="replace")
|
||||
if "err:" in content.lower():
|
||||
return {"error": "File not found or no SD card"}
|
||||
|
||||
echo_end = buf.index(b"\r\n") + 2
|
||||
data_buf = buf[echo_end:]
|
||||
|
||||
# Read 4-byte file size header
|
||||
while len(data_buf) < 4 and time.monotonic() < deadline:
|
||||
chunk = ser.read(4 - len(data_buf))
|
||||
if chunk:
|
||||
data_buf += chunk
|
||||
|
||||
if len(data_buf) < 4:
|
||||
return {"error": "Failed to read file size header"}
|
||||
|
||||
file_size = struct.unpack_from("<I", data_buf, 0)[0]
|
||||
data_buf = data_buf[4:]
|
||||
|
||||
# Read file data
|
||||
while len(data_buf) < file_size and time.monotonic() < deadline:
|
||||
remaining = file_size - len(data_buf)
|
||||
chunk = ser.read(min(remaining, 4096))
|
||||
if chunk:
|
||||
data_buf += chunk
|
||||
|
||||
# Drain trailing prompt
|
||||
trailing = b""
|
||||
while time.monotonic() < deadline:
|
||||
chunk = ser.read(max(1, ser.in_waiting))
|
||||
if chunk:
|
||||
trailing += chunk
|
||||
if b"ch> " in trailing or not chunk:
|
||||
break
|
||||
|
||||
return {
|
||||
"filename": filename,
|
||||
"size": file_size,
|
||||
"data_base64": base64.b64encode(data_buf[:file_size]).decode("ascii"),
|
||||
}
|
||||
finally:
|
||||
ser.timeout = old_timeout
|
||||
|
||||
def sd_delete(self, filename: str) -> dict:
|
||||
"""Delete a file from the SD card.
|
||||
|
||||
Args:
|
||||
filename: Name of the file to delete
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("sd_delete"):
|
||||
return {"error": "SD card commands not supported by this firmware/hardware"}
|
||||
lines = self._protocol.send_text_command(f"sd_delete {filename}", timeout=10.0)
|
||||
success = any("ok" in line.lower() for line in lines)
|
||||
return {"filename": filename, "deleted": success, "response": lines}
|
||||
|
||||
def time(
|
||||
self,
|
||||
field: str | None = None,
|
||||
value: int | None = None,
|
||||
) -> dict:
|
||||
"""Get or set RTC (real-time clock) time.
|
||||
|
||||
With no args, returns current date/time. With field + value, sets
|
||||
a specific time component.
|
||||
|
||||
Args:
|
||||
field: Time field to set: y, m, d, h, min, sec, or ppm
|
||||
value: Value for the field (0-99 for date/time, float for ppm)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("time"):
|
||||
return {"error": "RTC (time) command not supported by this firmware/hardware"}
|
||||
if field is not None and value is not None:
|
||||
lines = self._protocol.send_text_command(f"time {field} {value}")
|
||||
return {"field": field, "value": value, "response": lines}
|
||||
lines = self._protocol.send_text_command("time")
|
||||
# Response format: "20YY/MM/DD HH:MM:SS\nusage: ..."
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if "/" in line and ":" in line and "usage" not in line.lower():
|
||||
return {"datetime": line}
|
||||
return {"response": lines}
|
||||
323
src/mcnanovna/tools/diagnostics.py
Normal file
323
src/mcnanovna/tools/diagnostics.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""DiagnosticsMixin — low-level hardware diagnostics, register access, and debug tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class DiagnosticsMixin:
|
||||
"""Diagnostic tools: i2c, si, lcd, threads, stat, sample, test, gain, dump, port, offset, dac, usart_cfg, usart, band."""
|
||||
|
||||
def i2c(self, page: int, reg: int, data: int) -> dict:
|
||||
"""Write to an I2C register on the TLV320AIC3204 audio codec.
|
||||
|
||||
Low-level diagnostic tool for direct codec register access.
|
||||
|
||||
Args:
|
||||
page: I2C register page number
|
||||
reg: Register address within the page
|
||||
data: Byte value to write (0-255)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("i2c"):
|
||||
return {"error": "i2c command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"i2c {page} {reg} {data}")
|
||||
return {"page": page, "reg": reg, "data": data, "response": lines}
|
||||
|
||||
def si(self, reg: int, value: int) -> dict:
|
||||
"""Write to a Si5351 frequency synthesizer register.
|
||||
|
||||
Low-level diagnostic tool for direct Si5351 register access.
|
||||
|
||||
Args:
|
||||
reg: Si5351 register address
|
||||
value: Byte value to write (0-255)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("si"):
|
||||
return {"error": "si (Si5351 register) command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"si {reg} {value}")
|
||||
return {"reg": reg, "value": value, "response": lines}
|
||||
|
||||
def lcd(self, register: int, data_bytes: list[int] | None = None) -> dict:
|
||||
"""Send a register command to the LCD display controller.
|
||||
|
||||
Low-level diagnostic tool. First arg is the register/command byte,
|
||||
remaining are data bytes.
|
||||
|
||||
Args:
|
||||
register: LCD register/command byte
|
||||
data_bytes: Additional data bytes (list of ints)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("lcd"):
|
||||
return {"error": "lcd command not supported by this firmware"}
|
||||
parts = [str(register)]
|
||||
if data_bytes:
|
||||
parts.extend(str(d) for d in data_bytes)
|
||||
lines = self._protocol.send_text_command(f"lcd {' '.join(parts)}")
|
||||
# Response: "ret = 0x..."
|
||||
for line in lines:
|
||||
if "ret" in line.lower():
|
||||
return {"register": register, "data_bytes": data_bytes or [], "result": line.strip()}
|
||||
return {"register": register, "data_bytes": data_bytes or [], "response": lines}
|
||||
|
||||
def threads(self) -> dict:
|
||||
"""List ChibiOS RTOS thread information.
|
||||
|
||||
Shows all running threads with their stack usage, priority, and state.
|
||||
Useful for diagnosing firmware issues.
|
||||
|
||||
TODO: When hardware with ENABLE_THREADS_COMMAND is available, explore
|
||||
representing ChibiOS threads as MCP Tasks (FastMCP tasks=True) so they
|
||||
surface in Claude Code's /tasks UI with live state tracking.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("threads"):
|
||||
return {"error": "threads command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command("threads")
|
||||
threads = []
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or "stklimit" in line.lower():
|
||||
continue # Skip header
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 8:
|
||||
threads.append(
|
||||
{
|
||||
"stk_limit": parts[0].strip(),
|
||||
"stack": parts[1].strip(),
|
||||
"stk_free": parts[2].strip(),
|
||||
"addr": parts[3].strip(),
|
||||
"refs": parts[4].strip(),
|
||||
"prio": parts[5].strip(),
|
||||
"state": parts[6].strip(),
|
||||
"name": parts[7].strip(),
|
||||
}
|
||||
)
|
||||
return {"threads": threads, "count": len(threads)}
|
||||
|
||||
def stat(self) -> dict:
|
||||
"""Get audio ADC statistics for both channels.
|
||||
|
||||
Returns average and RMS values for the reference and signal channels
|
||||
on both ports. Useful for diagnosing signal level issues.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("stat"):
|
||||
return {"error": "stat command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command("stat", timeout=10.0)
|
||||
channels = []
|
||||
current_ch: dict = {}
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith("Ch:"):
|
||||
if current_ch:
|
||||
channels.append(current_ch)
|
||||
current_ch = {"channel": line.split(":")[1].strip()}
|
||||
elif "average:" in line.lower():
|
||||
nums = re.findall(r"-?\d+", line)
|
||||
if len(nums) >= 2:
|
||||
current_ch["average_ref"] = int(nums[0])
|
||||
current_ch["average_signal"] = int(nums[1])
|
||||
elif "rms:" in line.lower():
|
||||
nums = re.findall(r"-?\d+", line)
|
||||
if len(nums) >= 2:
|
||||
current_ch["rms_ref"] = int(nums[0])
|
||||
current_ch["rms_signal"] = int(nums[1])
|
||||
if current_ch:
|
||||
channels.append(current_ch)
|
||||
return {"channels": channels}
|
||||
|
||||
def sample(self, mode: str) -> dict:
|
||||
"""Set the ADC sample capture function.
|
||||
|
||||
Controls what data the sample command captures from the audio ADC.
|
||||
|
||||
Args:
|
||||
mode: Sample mode — 'gamma' (complex reflection), 'ampl' (amplitude),
|
||||
or 'ref' (reference amplitude)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("sample"):
|
||||
return {"error": "sample command not supported by this firmware"}
|
||||
valid = {"gamma", "ampl", "ref"}
|
||||
if mode not in valid:
|
||||
return {"error": f"Invalid mode '{mode}'. Valid: {', '.join(sorted(valid))}"}
|
||||
lines = self._protocol.send_text_command(f"sample {mode}")
|
||||
return {"mode": mode, "response": lines}
|
||||
|
||||
def test(self) -> dict:
|
||||
"""Run hardware self-test.
|
||||
|
||||
Executes built-in hardware diagnostics. The specific tests depend
|
||||
on the firmware build configuration.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("test"):
|
||||
return {"error": "test command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command("test", timeout=30.0)
|
||||
return {"response": lines}
|
||||
|
||||
def gain(self, lgain: int, rgain: int | None = None) -> dict:
|
||||
"""Set audio codec gain levels.
|
||||
|
||||
Controls the TLV320AIC3204 PGA (Programmable Gain Amplifier).
|
||||
|
||||
Args:
|
||||
lgain: Left channel gain (0-95, in 0.5 dB steps)
|
||||
rgain: Right channel gain (0-95). If omitted, uses lgain for both.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("gain"):
|
||||
return {"error": "gain command not supported by this firmware"}
|
||||
if rgain is not None:
|
||||
lines = self._protocol.send_text_command(f"gain {lgain} {rgain}")
|
||||
return {"lgain": lgain, "rgain": rgain, "response": lines}
|
||||
lines = self._protocol.send_text_command(f"gain {lgain}")
|
||||
return {"lgain": lgain, "rgain": lgain, "response": lines}
|
||||
|
||||
def dump(self, channel: int = 0) -> dict:
|
||||
"""Dump raw audio ADC samples.
|
||||
|
||||
Captures and returns raw sample data from the audio buffer.
|
||||
Useful for signal analysis and debugging.
|
||||
|
||||
Args:
|
||||
channel: Audio channel to dump (0 or 1)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("dump"):
|
||||
return {"error": "dump command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"dump {channel}", timeout=10.0)
|
||||
samples = []
|
||||
for line in lines:
|
||||
for val in line.strip().split():
|
||||
try:
|
||||
samples.append(int(val))
|
||||
except ValueError:
|
||||
pass
|
||||
return {"channel": channel, "samples": samples, "count": len(samples)}
|
||||
|
||||
def port(self, port_num: int) -> dict:
|
||||
"""Select the active audio port (TX or RX).
|
||||
|
||||
Switches the TLV320AIC3204 codec between transmit and receive paths.
|
||||
|
||||
Args:
|
||||
port_num: Port number (0=TX, 1=RX)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("port"):
|
||||
return {"error": "port command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"port {port_num}")
|
||||
return {"port": port_num, "description": "TX" if port_num == 0 else "RX", "response": lines}
|
||||
|
||||
def offset(self, frequency_hz: int | None = None) -> dict:
|
||||
"""Get or set the variable IF frequency offset.
|
||||
|
||||
Adjusts the intermediate frequency offset used in the measurement
|
||||
pipeline. Only available on firmware builds with USE_VARIABLE_OFFSET.
|
||||
|
||||
Args:
|
||||
frequency_hz: IF offset frequency in Hz
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("offset"):
|
||||
return {"error": "offset command not supported by this firmware"}
|
||||
if frequency_hz is not None:
|
||||
lines = self._protocol.send_text_command(f"offset {frequency_hz}")
|
||||
return {"offset_hz": frequency_hz, "set": True, "response": lines}
|
||||
lines = self._protocol.send_text_command("offset")
|
||||
for line in lines:
|
||||
if "current:" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return {"offset_hz": int(parts[1].strip())}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
|
||||
def dac(self, value: int | None = None) -> dict:
|
||||
"""Get or set the DAC output value.
|
||||
|
||||
Controls the on-chip DAC (used for bias voltage or other analog output).
|
||||
Range is 0-4095 (12-bit).
|
||||
|
||||
Args:
|
||||
value: DAC value (0-4095)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("dac"):
|
||||
return {"error": "dac command not supported by this firmware/hardware"}
|
||||
if value is not None:
|
||||
lines = self._protocol.send_text_command(f"dac {value}")
|
||||
return {"dac_value": value, "set": True, "response": lines}
|
||||
lines = self._protocol.send_text_command("dac")
|
||||
for line in lines:
|
||||
if "current:" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
return {"dac_value": int(parts[1].strip())}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
|
||||
def usart_cfg(self, baudrate: int | None = None) -> dict:
|
||||
"""Get or set the USART serial port configuration.
|
||||
|
||||
Controls the secondary serial port (USART) baud rate. The USART can
|
||||
be used for external device communication or serial console.
|
||||
|
||||
Args:
|
||||
baudrate: Baud rate (minimum 300). Omit to query current setting.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("usart_cfg"):
|
||||
return {"error": "usart_cfg command not supported by this firmware"}
|
||||
if baudrate is not None:
|
||||
lines = self._protocol.send_text_command(f"usart_cfg {baudrate}")
|
||||
return {"baudrate": baudrate, "set": True, "response": lines}
|
||||
lines = self._protocol.send_text_command("usart_cfg")
|
||||
# Response: "Serial: <baud> baud"
|
||||
for line in lines:
|
||||
if "baud" in line.lower():
|
||||
m = re.search(r"(\d+)\s*baud", line, re.IGNORECASE)
|
||||
if m:
|
||||
return {"baudrate": int(m.group(1))}
|
||||
return {"response": lines}
|
||||
|
||||
def usart(self, data: str, timeout_ms: int = 200) -> dict:
|
||||
"""Send data through the USART serial port and read the response.
|
||||
|
||||
Forwards data to an external device connected to the USART port
|
||||
and returns any response received within the timeout period.
|
||||
|
||||
Args:
|
||||
data: String data to send
|
||||
timeout_ms: Response timeout in milliseconds (default 200)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("usart"):
|
||||
return {"error": "usart command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"usart {data} {timeout_ms}", timeout=5.0)
|
||||
return {"sent": data, "timeout_ms": timeout_ms, "response": lines}
|
||||
|
||||
def band(self, index: int, param: str, value: int) -> dict:
|
||||
"""Configure frequency band parameters for the Si5351 synthesizer.
|
||||
|
||||
Low-level control of per-band synthesizer settings. Parameters include
|
||||
mode, frequency, divider, multiplier, and power settings.
|
||||
|
||||
Args:
|
||||
index: Band index
|
||||
param: Parameter name — mode, freq, div, mul, omul, pow, opow, l, r, lr, adj
|
||||
value: Parameter value
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("b"):
|
||||
return {"error": "band (b) command not supported by this firmware"}
|
||||
lines = self._protocol.send_text_command(f"b {index} {param} {value}")
|
||||
return {"index": index, "param": param, "value": value, "response": lines}
|
||||
161
src/mcnanovna/tools/display.py
Normal file
161
src/mcnanovna/tools/display.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""DisplayMixin — trace, transform, smoothing, touch, and remote desktop tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DisplayMixin:
|
||||
"""Display and touch tools: trace, transform, smooth, touchcal, touchtest, refresh, touch, release."""
|
||||
|
||||
def trace(
|
||||
self,
|
||||
number: int | None = None,
|
||||
trace_type: str | None = None,
|
||||
channel: int | None = None,
|
||||
scale: float | None = None,
|
||||
refpos: float | None = None,
|
||||
) -> dict:
|
||||
"""Query or configure display traces.
|
||||
|
||||
Trace types: logmag, phase, delay, smith, polar, linear, swr, real, imag,
|
||||
r, x, z, zp, g, b, y, rp, xp, and many more.
|
||||
|
||||
Args:
|
||||
number: Trace number (0-3)
|
||||
trace_type: Display format (e.g. 'logmag', 'swr', 'smith')
|
||||
channel: Data channel (0=S11, 1=S21)
|
||||
scale: Y-axis scale value
|
||||
refpos: Reference position on display
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if number is not None:
|
||||
if trace_type is not None:
|
||||
cmd = f"trace {number} {trace_type}"
|
||||
if channel is not None:
|
||||
cmd += f" {channel}"
|
||||
self._protocol.send_text_command(cmd)
|
||||
elif scale is not None:
|
||||
self._protocol.send_text_command(f"trace {number} scale {scale}")
|
||||
elif refpos is not None:
|
||||
self._protocol.send_text_command(f"trace {number} refpos {refpos}")
|
||||
|
||||
lines = self._protocol.send_text_command("trace")
|
||||
return {"traces": lines}
|
||||
|
||||
def transform(self, mode: str | None = None) -> dict:
|
||||
"""Control time-domain transform mode.
|
||||
|
||||
Modes: 'on', 'off', 'impulse', 'step', 'bandpass', 'minimum', 'normal', 'maximum'.
|
||||
|
||||
Args:
|
||||
mode: Transform mode to set
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("transform"):
|
||||
return {"error": "transform command not supported by this firmware"}
|
||||
if mode is not None:
|
||||
lines = self._protocol.send_text_command(f"transform {mode}")
|
||||
return {"transform": mode, "response": lines}
|
||||
lines = self._protocol.send_text_command("transform")
|
||||
return {"transform_status": lines}
|
||||
|
||||
def smooth(self, factor: int | None = None) -> dict:
|
||||
"""Get or set trace smoothing factor.
|
||||
|
||||
Args:
|
||||
factor: Smoothing factor (0=off, higher=more smoothing)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if not self._has_capability("smooth"):
|
||||
return {"error": "smooth command not supported by this firmware"}
|
||||
if factor is not None:
|
||||
self._protocol.send_text_command(f"smooth {factor}")
|
||||
lines = self._protocol.send_text_command("smooth")
|
||||
return {"response": lines}
|
||||
|
||||
def touchcal(self) -> dict:
|
||||
"""Start touch screen calibration sequence.
|
||||
|
||||
Interactive: the device displays calibration targets. Touch the upper-left
|
||||
corner, then the lower-right corner when prompted. Returns the calibration
|
||||
parameters when complete.
|
||||
|
||||
Note: This is an interactive hardware procedure that requires physical
|
||||
touch input on the device screen.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("touchcal", timeout=30.0)
|
||||
# Parse "touch cal params: a b c d"
|
||||
for line in lines:
|
||||
if "touch cal params:" in line.lower():
|
||||
parts = line.split(":")
|
||||
if len(parts) >= 2:
|
||||
vals = parts[1].strip().split()
|
||||
if len(vals) >= 4:
|
||||
try:
|
||||
return {
|
||||
"calibrated": True,
|
||||
"params": [int(v) for v in vals[:4]],
|
||||
"response": lines,
|
||||
}
|
||||
except ValueError:
|
||||
pass
|
||||
return {"response": lines}
|
||||
|
||||
def touchtest(self) -> dict:
|
||||
"""Start touch screen test mode.
|
||||
|
||||
Enters a mode where touch points are drawn on screen for verification.
|
||||
Useful for checking touch calibration accuracy. Exit by sending another
|
||||
command or resetting.
|
||||
"""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("touchtest", timeout=10.0)
|
||||
return {"mode": "touchtest", "response": lines}
|
||||
|
||||
def refresh(self, enable: str | None = None) -> dict:
|
||||
"""Enable or disable remote desktop refresh mode.
|
||||
|
||||
When enabled, the device streams display updates over the serial
|
||||
connection for remote viewing.
|
||||
|
||||
Args:
|
||||
enable: 'on' to enable remote desktop, 'off' to disable
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if enable is not None:
|
||||
if enable not in ("on", "off"):
|
||||
return {"error": "enable must be 'on' or 'off'"}
|
||||
lines = self._protocol.send_text_command(f"refresh {enable}")
|
||||
return {"remote_desktop": enable, "response": lines}
|
||||
return {"error": "usage: refresh on|off"}
|
||||
|
||||
def touch(self, x: int, y: int) -> dict:
|
||||
"""Send a touch press event at screen coordinates.
|
||||
|
||||
Simulates a finger press on the NanoVNA touchscreen for remote control.
|
||||
Follow with 'release' to complete the touch gesture.
|
||||
|
||||
Args:
|
||||
x: X coordinate (0 to lcd_width-1)
|
||||
y: Y coordinate (0 to lcd_height-1)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command(f"touch {x} {y}")
|
||||
return {"action": "press", "x": x, "y": y, "response": lines}
|
||||
|
||||
def release(self, x: int = -1, y: int = -1) -> dict:
|
||||
"""Send a touch release event at screen coordinates.
|
||||
|
||||
Completes a touch gesture started with 'touch'. If coordinates
|
||||
are omitted (-1), releases at the last touch position.
|
||||
|
||||
Args:
|
||||
x: X coordinate (-1 for last position)
|
||||
y: Y coordinate (-1 for last position)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if x >= 0 and y >= 0:
|
||||
lines = self._protocol.send_text_command(f"release {x} {y}")
|
||||
else:
|
||||
lines = self._protocol.send_text_command("release")
|
||||
return {"action": "release", "x": x, "y": y, "response": lines}
|
||||
288
src/mcnanovna/tools/measurement.py
Normal file
288
src/mcnanovna/tools/measurement.py
Normal file
@ -0,0 +1,288 @@
|
||||
"""MeasurementMixin — essential sweep, scan, calibration, and data retrieval tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
from mcnanovna.protocol import (
|
||||
SCAN_MASK_BINARY,
|
||||
SCAN_MASK_NO_CALIBRATION,
|
||||
SCAN_MASK_OUT_DATA0,
|
||||
SCAN_MASK_OUT_DATA1,
|
||||
SCAN_MASK_OUT_FREQ,
|
||||
parse_float_pairs,
|
||||
parse_frequencies,
|
||||
parse_scan_binary,
|
||||
parse_scan_text,
|
||||
)
|
||||
|
||||
# Channel name mapping for the data command
|
||||
CHANNEL_NAMES = {
|
||||
0: "S11 (measured)",
|
||||
1: "S21 (measured)",
|
||||
2: "ETERM_ED (directivity)",
|
||||
3: "ETERM_ES (source match)",
|
||||
4: "ETERM_ER (reflection tracking)",
|
||||
5: "ETERM_ET (transmission tracking)",
|
||||
6: "ETERM_EX (isolation)",
|
||||
}
|
||||
|
||||
|
||||
class MeasurementMixin:
|
||||
"""Tier 1 tools: info, sweep, scan, data, frequencies, marker, cal, save, recall, pause, resume."""
|
||||
|
||||
def info(self) -> dict:
|
||||
"""Get NanoVNA device information: board, firmware version, capabilities, display size, and hardware parameters."""
|
||||
self._ensure_connected()
|
||||
di = self._protocol.device_info
|
||||
return {
|
||||
"board": di.board,
|
||||
"version": di.version,
|
||||
"max_points": di.max_points,
|
||||
"if_hz": di.if_hz,
|
||||
"adc_hz": di.adc_hz,
|
||||
"lcd_width": di.lcd_width,
|
||||
"lcd_height": di.lcd_height,
|
||||
"architecture": di.architecture,
|
||||
"platform": di.platform,
|
||||
"build_time": di.build_time,
|
||||
"capabilities": di.capabilities,
|
||||
"port": self._port,
|
||||
}
|
||||
|
||||
def sweep(
|
||||
self,
|
||||
start_hz: int | None = None,
|
||||
stop_hz: int | None = None,
|
||||
points: int | None = None,
|
||||
) -> dict:
|
||||
"""Get or set the sweep frequency range. With no args, returns current settings. With args, sets new sweep parameters.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz (e.g. 50000 for 50 kHz)
|
||||
stop_hz: Stop frequency in Hz (e.g. 900000000 for 900 MHz)
|
||||
points: Number of sweep points (max depends on hardware: 101 or 401)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if start_hz is not None:
|
||||
parts = [str(start_hz)]
|
||||
if stop_hz is not None:
|
||||
parts.append(str(stop_hz))
|
||||
if points is not None:
|
||||
parts.append(str(points))
|
||||
self._protocol.send_text_command(f"sweep {' '.join(parts)}")
|
||||
|
||||
lines = self._protocol.send_text_command("sweep")
|
||||
# Response: "start_hz stop_hz points"
|
||||
if lines:
|
||||
parts = lines[0].strip().split()
|
||||
if len(parts) >= 3:
|
||||
return {
|
||||
"start_hz": int(parts[0]),
|
||||
"stop_hz": int(parts[1]),
|
||||
"points": int(parts[2]),
|
||||
}
|
||||
return {"start_hz": 0, "stop_hz": 0, "points": 0}
|
||||
|
||||
async def scan(
|
||||
self,
|
||||
start_hz: int,
|
||||
stop_hz: int,
|
||||
points: int = 101,
|
||||
s11: bool = True,
|
||||
s21: bool = True,
|
||||
apply_cal: bool = True,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Perform a frequency sweep and return S-parameter measurement data.
|
||||
|
||||
This is the primary measurement tool. It configures the sweep range,
|
||||
triggers acquisition, and returns calibrated S11/S21 complex data.
|
||||
|
||||
Args:
|
||||
start_hz: Start frequency in Hz (min ~600, max 2000000000)
|
||||
stop_hz: Stop frequency in Hz
|
||||
points: Number of measurement points (1 to device max, typically 101 or 401)
|
||||
s11: Include S11 reflection data
|
||||
s21: Include S21 transmission data
|
||||
apply_cal: Apply stored calibration correction (set False for raw data)
|
||||
"""
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 4, "Connecting to NanoVNA...")
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
|
||||
mask = SCAN_MASK_OUT_FREQ
|
||||
if s11:
|
||||
mask |= SCAN_MASK_OUT_DATA0
|
||||
if s21:
|
||||
mask |= SCAN_MASK_OUT_DATA1
|
||||
if not apply_cal:
|
||||
mask |= SCAN_MASK_NO_CALIBRATION
|
||||
|
||||
use_binary = self._has_capability("scan_bin")
|
||||
|
||||
await _progress(ctx, 2, 4, "Sending scan command...")
|
||||
|
||||
if use_binary:
|
||||
binary_mask = mask | SCAN_MASK_BINARY
|
||||
await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
|
||||
rx_mask, rx_points, raw = await asyncio.to_thread(
|
||||
self._protocol.send_binary_scan, start_hz, stop_hz, points, binary_mask
|
||||
)
|
||||
scan_points = parse_scan_binary(rx_mask, rx_points, raw)
|
||||
else:
|
||||
await _progress(ctx, 3, 4, f"Waiting for sweep data ({points} points)...")
|
||||
lines = await asyncio.to_thread(
|
||||
self._protocol.send_text_command,
|
||||
f"scan {start_hz} {stop_hz} {points} {mask}",
|
||||
30.0,
|
||||
)
|
||||
scan_points = parse_scan_text(lines, mask)
|
||||
|
||||
await _progress(ctx, 4, 4, "Parsing measurement data...")
|
||||
|
||||
data = []
|
||||
for pt in scan_points:
|
||||
entry: dict = {}
|
||||
if pt.frequency_hz is not None:
|
||||
entry["frequency_hz"] = pt.frequency_hz
|
||||
if pt.s11 is not None:
|
||||
entry["s11"] = {"real": pt.s11[0], "imag": pt.s11[1]}
|
||||
if pt.s21 is not None:
|
||||
entry["s21"] = {"real": pt.s21[0], "imag": pt.s21[1]}
|
||||
data.append(entry)
|
||||
|
||||
return {
|
||||
"start_hz": start_hz,
|
||||
"stop_hz": stop_hz,
|
||||
"points": len(data),
|
||||
"binary": use_binary,
|
||||
"mask": mask,
|
||||
"data": data,
|
||||
}
|
||||
|
||||
def data(self, channel: int = 0) -> dict:
|
||||
"""Read measurement or calibration data arrays from device memory.
|
||||
|
||||
Channels: 0=S11 measured, 1=S21 measured, 2=directivity, 3=source match,
|
||||
4=reflection tracking, 5=transmission tracking, 6=isolation.
|
||||
|
||||
Args:
|
||||
channel: Data array index (0-6)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if channel < 0 or channel > 6:
|
||||
return {"error": "Channel must be 0-6"}
|
||||
lines = self._protocol.send_text_command(f"data {channel}")
|
||||
pairs = parse_float_pairs(lines)
|
||||
return {
|
||||
"channel": channel,
|
||||
"channel_name": CHANNEL_NAMES.get(channel, f"channel {channel}"),
|
||||
"points": len(pairs),
|
||||
"data": [{"real": r, "imag": i} for r, i in pairs],
|
||||
}
|
||||
|
||||
def frequencies(self) -> dict:
|
||||
"""Get the list of frequency points for the current sweep configuration."""
|
||||
self._ensure_connected()
|
||||
lines = self._protocol.send_text_command("frequencies")
|
||||
freqs = parse_frequencies(lines)
|
||||
return {"count": len(freqs), "frequencies_hz": freqs}
|
||||
|
||||
def marker(
|
||||
self,
|
||||
number: int | None = None,
|
||||
action: str | None = None,
|
||||
index: int | None = None,
|
||||
) -> dict:
|
||||
"""Query or control markers on the NanoVNA display.
|
||||
|
||||
With no args, lists all active markers. With number + action, controls a specific marker.
|
||||
|
||||
Args:
|
||||
number: Marker number (1-8)
|
||||
action: Action to perform: 'on', 'off', or omit to query
|
||||
index: Set marker to this sweep point index
|
||||
"""
|
||||
self._ensure_connected()
|
||||
if number is not None:
|
||||
if action is not None:
|
||||
self._protocol.send_text_command(f"marker {number} {action}")
|
||||
elif index is not None:
|
||||
self._protocol.send_text_command(f"marker {number} {index}")
|
||||
|
||||
lines = self._protocol.send_text_command("marker")
|
||||
markers = []
|
||||
for line in lines:
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
markers.append(
|
||||
{
|
||||
"id": int(parts[0]),
|
||||
"index": int(parts[1]),
|
||||
"frequency_hz": int(parts[2]),
|
||||
}
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
return {"markers": markers}
|
||||
|
||||
async def cal(self, step: str | None = None, ctx: Context | None = None) -> dict:
|
||||
"""Query calibration status or perform a calibration step.
|
||||
|
||||
Steps: 'load', 'open', 'short', 'thru', 'isoln', 'done', 'on', 'off', 'reset'.
|
||||
With no args, returns current calibration status.
|
||||
|
||||
Args:
|
||||
step: Calibration step to execute
|
||||
"""
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await asyncio.to_thread(self._ensure_connected)
|
||||
if step is not None:
|
||||
valid = {"load", "open", "short", "thru", "isoln", "done", "on", "off", "reset"}
|
||||
if step not in valid:
|
||||
return {"error": f"Invalid step '{step}'. Valid: {', '.join(sorted(valid))}"}
|
||||
await _progress(ctx, 1, 2, f"Sending calibration command: {step}...")
|
||||
lines = await asyncio.to_thread(self._protocol.send_text_command, f"cal {step}", 10.0)
|
||||
await _progress(ctx, 2, 2, f"Calibration step '{step}' complete")
|
||||
return {"step": step, "response": lines}
|
||||
|
||||
lines = await asyncio.to_thread(self._protocol.send_text_command, "cal")
|
||||
return {"status": lines}
|
||||
|
||||
def save(self, slot: int) -> dict:
|
||||
"""Save current calibration and configuration to a flash memory slot.
|
||||
|
||||
Args:
|
||||
slot: Save slot number (0-4 on NanoVNA-H, 0-6 on NanoVNA-H4)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
self._protocol.send_text_command(f"save {slot}")
|
||||
return {"slot": slot, "saved": True}
|
||||
|
||||
def recall(self, slot: int) -> dict:
|
||||
"""Recall calibration and configuration from a flash memory slot.
|
||||
|
||||
Args:
|
||||
slot: Save slot number (0-4 on NanoVNA-H, 0-6 on NanoVNA-H4)
|
||||
"""
|
||||
self._ensure_connected()
|
||||
self._protocol.send_text_command(f"recall {slot}", timeout=5.0)
|
||||
return {"slot": slot, "recalled": True}
|
||||
|
||||
def pause(self) -> dict:
|
||||
"""Pause the continuous sweep. Measurements freeze at current values."""
|
||||
self._ensure_connected()
|
||||
self._protocol.send_text_command("pause")
|
||||
return {"sweep": "paused"}
|
||||
|
||||
def resume(self) -> dict:
|
||||
"""Resume continuous sweep after pause."""
|
||||
self._ensure_connected()
|
||||
self._protocol.send_text_command("resume")
|
||||
return {"sweep": "running"}
|
||||
213
src/mcnanovna/tools/pattern_import.py
Normal file
213
src/mcnanovna/tools/pattern_import.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""PatternImportMixin — MCP tools for importing measured antenna patterns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp import Context
|
||||
|
||||
|
||||
class PatternImportMixin:
|
||||
"""Pattern import tools: import CSV, EMCAR, NEC2, S1P files and list formats."""
|
||||
|
||||
async def import_pattern_csv(
|
||||
self,
|
||||
content: str,
|
||||
frequency_hz: float | None = None,
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Import an antenna pattern from CSV data.
|
||||
|
||||
Auto-detects 2-column (angle, gain) vs 3-column (theta, phi, gain) from
|
||||
the header. Flexible header names: theta/elevation, phi/azimuth,
|
||||
gain/gain_dbi/amplitude/db.
|
||||
|
||||
For 2-column data, the angle column name determines the cut plane —
|
||||
the single cut is synthesized into a full 3D pattern with a sin(theta) taper.
|
||||
|
||||
Returns the standardized {theta_deg, phi_deg, gain_dbi} pattern dict
|
||||
for 3D visualization.
|
||||
|
||||
Args:
|
||||
content: CSV file content as string
|
||||
frequency_hz: Operating frequency in Hz (optional, for metadata)
|
||||
theta_step: Output grid polar angle step in degrees (default 2)
|
||||
phi_step: Output grid azimuthal angle step in degrees (default 5)
|
||||
"""
|
||||
from mcnanovna.pattern_import import parse_csv_pattern
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 2, "Parsing CSV pattern data...")
|
||||
result = parse_csv_pattern(content, frequency_hz=frequency_hz)
|
||||
await _progress(ctx, 2, 2, f"Imported CSV pattern: {result.get('import_info', {}).get('points', '?')} points")
|
||||
return result
|
||||
|
||||
async def import_pattern_emcar(
|
||||
self,
|
||||
content: str,
|
||||
frequency_hz: float | None = None,
|
||||
reference_dbi: float | None = None,
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Import an antenna pattern from EMCAR vna.dat format.
|
||||
|
||||
EMCAR records angle + amplitude_dBV pairs from a positioner-driven
|
||||
antenna range (typically HP8754A VNA). Lines starting with # are comments.
|
||||
|
||||
The gnuplot transform is applied: (-angle+90) rotation and
|
||||
20*log10(amplitude+0.01) voltage-to-dB conversion. This produces a
|
||||
single azimuth cut which is synthesized into a 3D pattern.
|
||||
|
||||
Without a reference antenna, the pattern shows relative shape only.
|
||||
Pass reference_dbi to offset to absolute gain.
|
||||
|
||||
Args:
|
||||
content: vna.dat file content as string
|
||||
frequency_hz: Operating frequency in Hz (optional)
|
||||
reference_dbi: Reference antenna gain offset in dBi (optional)
|
||||
theta_step: Output grid polar angle step in degrees (default 2)
|
||||
phi_step: Output grid azimuthal angle step in degrees (default 5)
|
||||
"""
|
||||
from mcnanovna.pattern_import import parse_emcar_vna_dat
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 2, "Parsing EMCAR vna.dat data...")
|
||||
result = parse_emcar_vna_dat(content, frequency_hz=frequency_hz, reference_dbi=reference_dbi)
|
||||
await _progress(ctx, 2, 2, f"Imported EMCAR pattern: {result.get('import_info', {}).get('points', '?')} points")
|
||||
return result
|
||||
|
||||
async def import_pattern_nec2(
|
||||
self,
|
||||
content: str,
|
||||
polarization: str = "total",
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Import an antenna pattern from NEC2 radiation output.
|
||||
|
||||
Parses the RADIATION PATTERNS section from NEC2/NEC4 output files.
|
||||
Extracts frequency from the header and provides full theta x phi gain data.
|
||||
|
||||
Supports polarization selection: 'total' (default), 'vert', or 'hor'.
|
||||
Values of -999.99 in NEC2 output are mapped to a -40 dBi floor.
|
||||
|
||||
Args:
|
||||
content: NEC2 output file content as string
|
||||
polarization: Gain column to use — 'total', 'vert', or 'hor' (default 'total')
|
||||
theta_step: Output grid polar angle step in degrees (default 2)
|
||||
phi_step: Output grid azimuthal angle step in degrees (default 5)
|
||||
"""
|
||||
from mcnanovna.pattern_import import parse_nec2_radiation
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 2, "Parsing NEC2 radiation pattern...")
|
||||
result = parse_nec2_radiation(content, polarization=polarization)
|
||||
await _progress(ctx, 2, 2, f"Imported NEC2 pattern: {result.get('import_info', {}).get('points', '?')} points")
|
||||
return result
|
||||
|
||||
async def import_pattern_s1p(
|
||||
self,
|
||||
content: str,
|
||||
antenna_type: str = "auto",
|
||||
theta_step: float = 2.0,
|
||||
phi_step: float = 5.0,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""Import S-parameters from a Touchstone S1P file and generate a pattern.
|
||||
|
||||
Parses S11 complex data, finds the resonant frequency (minimum |S11|),
|
||||
computes impedance, and generates an analytical 3D pattern using the
|
||||
Phase 1 antenna models. This bridges external VNA measurements to
|
||||
pattern visualization.
|
||||
|
||||
Supports RI (real/imaginary), MA (magnitude/angle), and DB (dB/angle)
|
||||
Touchstone formats. Reference impedance and frequency units are read
|
||||
from the option line.
|
||||
|
||||
Args:
|
||||
content: Touchstone .s1p file content as string
|
||||
antenna_type: Antenna model — 'dipole', 'monopole', 'efhw', 'loop', 'patch', or 'auto'
|
||||
theta_step: Output grid polar angle step in degrees (default 2)
|
||||
phi_step: Output grid azimuthal angle step in degrees (default 5)
|
||||
"""
|
||||
from mcnanovna.pattern_import import parse_touchstone_s1p
|
||||
from mcnanovna.tools import _progress
|
||||
|
||||
await _progress(ctx, 1, 2, "Parsing Touchstone S1P data...")
|
||||
result = parse_touchstone_s1p(content, antenna_type=antenna_type)
|
||||
await _progress(
|
||||
ctx,
|
||||
2,
|
||||
2,
|
||||
f"Generated pattern from S1P: {result.get('antenna_type')} at "
|
||||
f"{result.get('import_info', {}).get('resonant_frequency_hz', '?')} Hz",
|
||||
)
|
||||
return result
|
||||
|
||||
async def list_pattern_formats(
|
||||
self,
|
||||
ctx: Context | None = None,
|
||||
) -> dict:
|
||||
"""List supported pattern import formats with descriptions and examples.
|
||||
|
||||
Returns details about each format including file extensions, expected
|
||||
data structure, and example content for testing.
|
||||
"""
|
||||
return {
|
||||
"formats": [
|
||||
{
|
||||
"name": "CSV",
|
||||
"extensions": [".csv"],
|
||||
"tool": "import_pattern_csv",
|
||||
"description": (
|
||||
"Comma/semicolon/tab-separated pattern data. "
|
||||
"Auto-detects 2-column (angle, gain) or 3-column (theta, phi, gain) from headers."
|
||||
),
|
||||
"example_3col": "theta,phi,gain_dbi\n0,0,-40\n90,0,2.15\n90,90,2.15\n180,0,-40",
|
||||
"example_2col": "azimuth,gain_dbi\n0,2.15\n90,1.5\n180,0.8\n270,1.5",
|
||||
},
|
||||
{
|
||||
"name": "EMCAR vna.dat",
|
||||
"extensions": [".dat"],
|
||||
"tool": "import_pattern_emcar",
|
||||
"description": (
|
||||
"EMCAR antenna range format: angle amplitude_dBV pairs. "
|
||||
"Single azimuth cut from positioner-driven measurement. "
|
||||
"Gnuplot transform applied: (-angle+90) rotation, 20*log10(amplitude+0.01)."
|
||||
),
|
||||
"example": "# EMCAR measurement\n0 0.5\n45 0.8\n90 1.0\n135 0.8\n180 0.5\n225 0.3\n270 0.2\n315 0.3",
|
||||
"reference": "https://emcar.sourceforge.net/",
|
||||
},
|
||||
{
|
||||
"name": "NEC2 Radiation",
|
||||
"extensions": [".out", ".nec"],
|
||||
"tool": "import_pattern_nec2",
|
||||
"description": (
|
||||
"NEC2/NEC4 output file with RADIATION PATTERNS section. "
|
||||
"Full theta x phi grid with VERT, HOR, and TOTAL gain columns. "
|
||||
"Frequency auto-detected from header."
|
||||
),
|
||||
"polarization_options": ["total", "vert", "hor"],
|
||||
},
|
||||
{
|
||||
"name": "Touchstone S1P",
|
||||
"extensions": [".s1p"],
|
||||
"tool": "import_pattern_s1p",
|
||||
"description": (
|
||||
"Touchstone S-parameter file (S11 only). "
|
||||
"Finds resonance, computes impedance, generates analytical pattern. "
|
||||
"Supports RI, MA, and DB data formats."
|
||||
),
|
||||
"note": "Generates an analytical pattern from S11 data — not a measured pattern.",
|
||||
},
|
||||
],
|
||||
"common_workflow": (
|
||||
"1. Read the file content\n"
|
||||
"2. Call the appropriate import_pattern_* tool with the content string\n"
|
||||
"3. The tool returns the standard {theta_deg, phi_deg, gain_dbi} pattern dict\n"
|
||||
"4. If the web UI is running, the pattern is automatically available for 3D rendering"
|
||||
),
|
||||
}
|
||||
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()
|
||||
299
src/mcnanovna/webui/api.py
Normal file
299
src/mcnanovna/webui/api.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""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, File, Form, UploadFile, 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
|
||||
|
||||
@app.post("/api/pattern/import")
|
||||
async def api_pattern_import(
|
||||
file: UploadFile = File(...),
|
||||
format: str | None = Form(None),
|
||||
frequency_hz: float | None = Form(None),
|
||||
polarization: str = Form("total"),
|
||||
antenna_type: str = Form("auto"),
|
||||
reference_dbi: float | None = Form(None),
|
||||
):
|
||||
"""Import a pattern file (CSV, EMCAR, NEC2, or S1P) via multipart upload."""
|
||||
from mcnanovna.pattern_import import detect_format, parse_pattern
|
||||
|
||||
content = (await file.read()).decode("utf-8", errors="replace")
|
||||
filename = file.filename or "unknown"
|
||||
|
||||
# Auto-detect format from extension if not specified
|
||||
if not format:
|
||||
format = detect_format(filename, content)
|
||||
|
||||
kwargs: dict = {}
|
||||
if format == "nec2":
|
||||
kwargs["polarization"] = polarization
|
||||
elif format == "s1p":
|
||||
kwargs["antenna_type"] = antenna_type
|
||||
elif format == "emcar" and reference_dbi is not None:
|
||||
kwargs["reference_dbi"] = reference_dbi
|
||||
|
||||
pattern = parse_pattern(content, format, frequency_hz=frequency_hz, **kwargs)
|
||||
|
||||
# Add filename to import_info
|
||||
if "import_info" in pattern:
|
||||
pattern["import_info"]["filename"] = filename
|
||||
else:
|
||||
pattern["import_info"] = {"format": format, "filename": filename}
|
||||
|
||||
await _broadcast_pattern(pattern)
|
||||
return pattern
|
||||
|
||||
# ── Positioner endpoints ─────────────────────────────────────
|
||||
|
||||
@app.get("/api/positioner/status")
|
||||
async def api_positioner_status():
|
||||
"""Get antenna positioner status (position, moving, homed)."""
|
||||
try:
|
||||
from mcnanovna.positioner import Positioner
|
||||
|
||||
pos = Positioner()
|
||||
return await pos.status()
|
||||
except ImportError:
|
||||
return {"error": "Positioner support requires: pip install mcnanovna[positioner]"}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
@app.post("/api/positioner/move")
|
||||
async def api_positioner_move(request_data: dict):
|
||||
"""Move positioner to absolute theta/phi position."""
|
||||
try:
|
||||
from mcnanovna.positioner import Positioner
|
||||
|
||||
pos = Positioner()
|
||||
theta = request_data.get("theta_deg", 0)
|
||||
phi = request_data.get("phi_deg", 0)
|
||||
result = await pos.move(theta, phi)
|
||||
if request_data.get("wait", True):
|
||||
result = await pos.wait_until_stopped()
|
||||
return result
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
@app.post("/api/positioner/home")
|
||||
async def api_positioner_home(request_data: dict | None = None):
|
||||
"""Home one or both positioner axes."""
|
||||
try:
|
||||
from mcnanovna.positioner import Positioner
|
||||
|
||||
pos = Positioner()
|
||||
axis = (request_data or {}).get("axis", "both")
|
||||
return await pos.home(axis)
|
||||
except Exception as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
# ── 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-C5SDH5i7.css
Normal file
1
src/mcnanovna/webui/static/assets/index-C5SDH5i7.css
Normal file
File diff suppressed because one or more lines are too long
4022
src/mcnanovna/webui/static/assets/index-DVS5g-QC.js
Normal file
4022
src/mcnanovna/webui/static/assets/index-DVS5g-QC.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-DVS5g-QC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C5SDH5i7.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
|
||||
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]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@ -416,6 +425,21 @@ lua = [
|
||||
{ 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]]
|
||||
name = "fastmcp"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
@ -703,7 +763,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcnanovna"
|
||||
version = "2026.1.30"
|
||||
version = "2026.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
@ -711,12 +771,21 @@ dependencies = [
|
||||
{ name = "pyserial" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
webui = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", marker = "extra == 'webui'", specifier = ">=0.115.0" },
|
||||
{ name = "fastmcp", specifier = ">=2.14.0" },
|
||||
{ name = "pillow", specifier = ">=11.0.0" },
|
||||
{ name = "pyserial", specifier = ">=3.5" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'webui'", specifier = ">=0.34.0" },
|
||||
]
|
||||
provides-extras = ["webui"]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
@ -1550,15 +1619,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.52.1"
|
||||
version = "0.50.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@ -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" },
|
||||
]
|
||||
|
||||
[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]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user