Files
toroid/templates/index.html
2026-02-13 14:20:30 -06:00

1035 lines
35 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toroid Transformer Designer</title>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f0f2f5;
color: #333;
font-size: 14px;
}
/* ---- Top bar ---- */
.topbar {
background: linear-gradient(135deg, #1e3a5f 0%, #2d6a9f 100%);
color: white;
padding: 10px 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.topbar h1 { font-size: 18px; font-weight: 600; }
.topbar .spacer { flex: 1; }
/* ---- Layout: two-column ---- */
.layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 12px;
padding: 12px;
min-height: calc(100vh - 44px);
}
/* ---- Left column: panels stacked ---- */
.left-col { display: flex; flex-direction: column; gap: 12px; }
/* ---- Right column: drawing + plots ---- */
.right-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
/* ---- Card ---- */
.card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
overflow: hidden;
}
.card-header {
background: #1e3a5f;
color: white;
padding: 8px 14px;
font-weight: 600;
font-size: 13px;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.card-body { padding: 14px; }
/* ---- Form elements ---- */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 8px;
}
.form-row.three { grid-template-columns: 1fr 1fr 1fr; }
.form-row.four { grid-template-columns: 1fr 1fr 1fr 1fr; }
.form-row.one { grid-template-columns: 1fr; }
label.field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: #555;
}
label.field span { font-weight: 500; }
input[type=number], input[type=text], select {
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 8px;
font-size: 13px;
width: 100%;
background: white;
}
input[type=number]:focus, input[type=text]:focus, select:focus {
outline: none;
border-color: #2d6a9f;
box-shadow: 0 0 0 2px rgba(45,106,159,0.2);
}
/* ---- Winding builder ---- */
.winding-block {
border: 1px solid #dde;
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
background: #fafbff;
position: relative;
}
.winding-block-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.winding-block-header input[type=text] {
flex: 1;
font-weight: 600;
font-size: 13px;
}
.btn-sm {
padding: 3px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.btn-remove { background: #fee2e2; color: #b91c1c; }
.btn-remove:hover { background: #fca5a5; }
.btn-add-seg { background: #dbeafe; color: #1d4ed8; }
.btn-add-seg:hover { background: #bfdbfe; }
.seg-row {
display: grid;
grid-template-columns: 60px 1fr 28px;
gap: 6px;
align-items: end;
margin-bottom: 6px;
}
.seg-row label.field span { white-space: nowrap; }
.btn-del-seg {
background: #fee2e2;
color: #b91c1c;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 15px;
height: 28px;
width: 28px;
line-height: 1;
}
.btn-del-seg:hover { background: #fca5a5; }
/* ---- Button row ---- */
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.btn {
padding: 8px 20px;
border: none;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: linear-gradient(135deg, #1e3a5f, #2d6a9f);
color: white;
box-shadow: 0 2px 8px rgba(29,78,137,0.3);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
.btn-secondary {
background: #e5e7eb;
color: #374151;
}
.btn-secondary:hover:not(:disabled) { background: #d1d5db; }
.btn-add-winding {
background: #ecfdf5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.btn-add-winding:hover { background: #d1fae5; }
/* ---- Frequencies / loads textarea ---- */
textarea {
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
font-family: monospace;
resize: vertical;
min-height: 60px;
}
textarea:focus {
outline: none;
border-color: #2d6a9f;
box-shadow: 0 0 0 2px rgba(45,106,159,0.2);
}
/* ---- Status / error messages ---- */
.msg {
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
display: none;
}
.msg.visible { display: block; }
.msg-error { background: #fee2e2; color: #b91c1c; border: 1px solid #fca5a5; }
.msg-info { background: #dbeafe; color: #1e40af; border: 1px solid #93c5fd; }
.msg-ok { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
/* ---- Design results table ---- */
.design-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 8px;
}
.design-table th {
background: #f1f5f9;
padding: 5px 8px;
text-align: left;
border-bottom: 2px solid #e2e8f0;
font-weight: 600;
color: #475569;
}
.design-table td {
padding: 5px 8px;
border-bottom: 1px solid #f1f5f9;
}
.design-table tr:last-child td { border-bottom: none; }
.badge-ok { color: #166534; font-weight: 600; }
.badge-bad { color: #b91c1c; font-weight: 600; }
/* ---- Drawing ---- */
#drawing-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
background: #f8fafc;
border-radius: 6px;
color: #94a3b8;
font-size: 13px;
}
#drawing-img { max-width: 100%; border-radius: 4px; display: none; }
/* ---- Plots ---- */
.plot-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 0 14px 8px;
flex-wrap: wrap;
}
.plot-toolbar label { font-size: 12px; font-weight: 500; color: #555; }
.plot-toolbar select { width: auto; }
.plots-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 0 14px 14px;
}
.plot-box { min-height: 300px; }
/* ---- Resizable plots card ---- */
#plots-card {
resize: vertical;
overflow: auto;
min-height: 360px;
}
/* Show a subtle resize hint in the bottom-right corner */
#plots-card::after {
content: '⠿';
position: absolute;
bottom: 4px;
right: 6px;
font-size: 12px;
color: #94a3b8;
pointer-events: none;
line-height: 1;
}
#plots-card { position: relative; }
/* ---- Sweep results table ---- */
.sweep-table-wrap {
overflow-x: auto;
padding: 0 14px 14px;
}
.sweep-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
white-space: nowrap;
}
.sweep-table th {
background: #1e3a5f;
color: white;
padding: 5px 8px;
text-align: right;
font-weight: 500;
position: sticky;
top: 0;
}
.sweep-table th:first-child { text-align: left; }
.sweep-table td {
padding: 4px 8px;
border-bottom: 1px solid #f1f5f9;
text-align: right;
color: #374151;
}
.sweep-table td:first-child { text-align: left; }
.sweep-table tr:nth-child(even) td { background: #f8fafc; }
.sweep-table tr:hover td { background: #e0f2fe; }
.sweep-table .cell-ok { color: #166534; font-weight: 600; }
.sweep-table .cell-bad { color: #b91c1c; }
.sweep-table .cell-null { color: #94a3b8; }
/* ---- Spinner ---- */
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- Responsive: collapse to single column on narrow screens ---- */
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.plots-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="topbar">
<h1>Toroid Transformer Designer</h1>
<div class="spacer"></div>
</div>
<div class="layout">
<!-- ===================== LEFT COLUMN ===================== -->
<div class="left-col">
<!-- Core Parameters -->
<div class="card">
<div class="card-header">Core Parameters</div>
<div class="card-body">
<div class="form-row three">
<label class="field"><span>ID (mm)</span><input type="number" id="core-ID" value="21.5" min="1" step="0.1"></label>
<label class="field"><span>OD (mm)</span><input type="number" id="core-OD" value="46.5" min="2" step="0.1"></label>
<label class="field"><span>Height (mm)</span><input type="number" id="core-H" value="22.8" min="1" step="0.1"></label>
</div>
<div class="form-row two">
<label class="field"><span>Ae (mm²) optional</span><input type="number" id="core-Ae" value="142.5" min="0" step="0.1" placeholder="geometric default"></label>
<label class="field"><span>Ve (mm³) optional</span><input type="number" id="core-Ve" value="15219" min="0" step="1" placeholder="Ae × Le"></label>
</div>
<div class="form-row one" style="margin-top:4px">
<label class="field"><span>Fill factor</span><input type="number" id="core-fill" value="0.35" min="0.1" max="0.9" step="0.01"></label>
</div>
</div>
</div>
<!-- Windings -->
<div class="card">
<div class="card-header">Windings</div>
<div class="card-body">
<div id="windings-container"></div>
<div class="btn-row">
<button class="btn btn-add-winding" onclick="addWinding()">+ Add Winding</button>
</div>
</div>
</div>
<!-- Design button + results -->
<div class="card">
<div class="card-header">Design</div>
<div class="card-body">
<div class="btn-row" style="margin-bottom:10px">
<button class="btn btn-primary" id="btn-design" onclick="runDesign()">Design Transformer</button>
</div>
<div id="design-msg" class="msg"></div>
<div id="design-results-container" style="display:none">
<div id="design-results-tables"></div>
</div>
</div>
</div>
<!-- Simulation Parameters -->
<div class="card">
<div class="card-header">Simulation</div>
<div class="card-body">
<div class="form-row one">
<label class="field">
<span>Frequencies (Hz) — one per line or comma-separated</span>
<textarea id="sim-freqs" rows="3">256
870
3140
8900</textarea>
</label>
</div>
<div class="form-row one">
<label class="field">
<span>Loads [R, X] (ohms) — one pair per line: R or R,X</span>
<textarea id="sim-loads" rows="4">10
50
100
200
600
2000</textarea>
</label>
</div>
<div class="form-row">
<label class="field"><span>Target power (W)</span><input type="number" id="sim-target" value="25" min="0.1" step="0.1"></label>
<label class="field"><span>Power tolerance (%)</span><input type="number" id="sim-tol" value="2" min="0.1" step="0.1"></label>
</div>
<div class="form-row">
<label class="field"><span>Vp min (V)</span><input type="number" id="sim-vpmin" value="1" min="0.1" step="0.5"></label>
<label class="field"><span>Vp max (V)</span><input type="number" id="sim-vpmax" value="50" min="1" step="1"></label>
</div>
<div class="form-row one">
<label class="field"><span>Vp steps</span><input type="number" id="sim-vpsteps" value="100" min="10" max="500" step="10"></label>
</div>
<details style="margin: 8px 0">
<summary style="cursor:pointer; font-size:12px; color:#2d6a9f; font-weight:500">Constraints (expand)</summary>
<div style="margin-top:8px">
<div class="form-row">
<label class="field"><span>B max (T)</span><input type="number" id="con-B" value="0.3" min="0.01" step="0.01"></label>
<label class="field"><span>Vp max (V)</span><input type="number" id="con-Vp" value="50" min="1" step="1"></label>
</div>
<div class="form-row">
<label class="field"><span>Vs max (V)</span><input type="number" id="con-Vs" value="120" min="1" step="1"></label>
<label class="field"><span>Ip max (A)</span><input type="number" id="con-Ip" value="3" min="0.01" step="0.1"></label>
</div>
<div class="form-row">
<label class="field"><span>Is max (A)</span><input type="number" id="con-Is" value="2" min="0.01" step="0.1"></label>
<label class="field"><span>P out max (W)</span><input type="number" id="con-Pout" value="100" min="0.1" step="1"></label>
</div>
</div>
</details>
<div class="btn-row">
<button class="btn btn-primary" id="btn-sim" onclick="runSweep()">Run Simulation</button>
</div>
<div id="sim-msg" class="msg" style="margin-top:8px"></div>
</div>
</div>
</div><!-- /left-col -->
<!-- ===================== RIGHT COLUMN ===================== -->
<div class="right-col">
<!-- Drawing -->
<div class="card">
<div class="card-header">Cross-section Drawing</div>
<div class="card-body" style="padding:10px">
<div id="drawing-container">
<span>Run Design to see drawing</span>
</div>
<img id="drawing-img" alt="Toroid cross-section">
</div>
</div>
<!-- Plots -->
<div class="card" id="plots-card" style="display:none">
<div class="card-header">Simulation Results</div>
<div class="plot-toolbar">
<label for="freq-select">Frequency:</label>
<select id="freq-select" onchange="updatePlots()"></select>
<span id="plot-status" style="font-size:12px;color:#64748b;margin-left:8px"></span>
</div>
<div class="plots-grid">
<div class="plot-box" id="plot-voltage"></div>
<div class="plot-box" id="plot-current"></div>
<div class="plot-box" id="plot-power"></div>
</div>
</div>
<!-- Results table -->
<div class="card" id="table-card" style="display:none">
<div class="card-header" style="justify-content:space-between">
<span>Results Table</span>
<a id="csv-download" href="#" download="sweep_results.csv"
style="color:#93c5fd;font-size:12px;font-weight:500;text-decoration:none"
onclick="return triggerCsvDownload()">&#8595; Download CSV</a>
</div>
<div class="sweep-table-wrap">
<table class="sweep-table" id="sweep-table">
<thead id="sweep-table-head"></thead>
<tbody id="sweep-table-body"></tbody>
</table>
</div>
</div>
</div><!-- /right-col -->
</div><!-- /layout -->
<script>
// ============================================================
// State
// ============================================================
let windingCount = 0;
let sweepData = null; // last successful sweep result
// ============================================================
// Winding builder
// ============================================================
function addWinding(name, taps, awgs) {
const container = document.getElementById('windings-container');
const id = windingCount++;
name = name || (id === 0 ? 'primary' : id === 1 ? 'secondary' : `winding${id+1}`);
taps = taps || [25];
awgs = awgs || [22];
const div = document.createElement('div');
div.className = 'winding-block';
div.dataset.id = id;
// Build segment rows HTML
let segHtml = '';
for (let i = 0; i < taps.length; i++) {
segHtml += segRowHtml(id, i, awgs[i] || 22, taps[i]);
}
div.innerHTML = `
<div class="winding-block-header">
<input type="text" class="winding-name" value="${name}" placeholder="name">
<button class="btn-sm btn-remove" onclick="removeWinding(${id})">Remove</button>
</div>
<div class="seg-list" id="segs-${id}">
${segHtml}
</div>
<button class="btn-sm btn-add-seg" onclick="addSegment(${id})" style="margin-top:4px">+ Add segment</button>
`;
container.appendChild(div);
}
function segRowHtml(wid, segIdx, awg, turns) {
const awgOptions = [20,21,22,23,24,25,26,27,28,29,30].map(a =>
`<option value="${a}" ${a == awg ? 'selected' : ''}>${a}</option>`
).join('');
return `
<div class="seg-row" data-seg="${segIdx}">
<label class="field"><span>AWG</span>
<select class="seg-awg">${awgOptions}</select>
</label>
<label class="field"><span>Turns (seg ${segIdx+1})</span>
<input type="number" class="seg-turns" value="${turns}" min="1" step="1">
</label>
<button class="btn-del-seg" onclick="removeSegment(this)" title="Remove segment">-</button>
</div>`;
}
function addSegment(wid) {
const list = document.getElementById(`segs-${wid}`);
const existing = list.querySelectorAll('.seg-row');
const newIdx = existing.length;
const div = document.createElement('div');
div.innerHTML = segRowHtml(wid, newIdx, 22, 50);
list.appendChild(div.firstElementChild);
renumberSegments(list);
}
function removeSegment(btn) {
const row = btn.closest('.seg-row');
const list = row.parentElement;
if (list.querySelectorAll('.seg-row').length <= 1) {
alert('Each winding must have at least one segment.');
return;
}
row.remove();
renumberSegments(list);
}
function renumberSegments(list) {
list.querySelectorAll('.seg-row').forEach((row, i) => {
row.dataset.seg = i;
const lbl = row.querySelector('.field:nth-child(2) span');
if (lbl) lbl.textContent = `Turns (seg ${i+1})`;
});
}
function removeWinding(id) {
const container = document.getElementById('windings-container');
if (container.querySelectorAll('.winding-block').length <= 1) {
alert('Need at least one winding.');
return;
}
container.querySelector(`.winding-block[data-id="${id}"]`).remove();
}
// ============================================================
// Build request payload from current UI state
// ============================================================
function buildPayload() {
const payload = {
ID_mm: parseFloat(document.getElementById('core-ID').value),
OD_mm: parseFloat(document.getElementById('core-OD').value),
height_mm: parseFloat(document.getElementById('core-H').value),
fill_factor: parseFloat(document.getElementById('core-fill').value),
windings: [],
};
const aeVal = document.getElementById('core-Ae').value.trim();
const veVal = document.getElementById('core-Ve').value.trim();
if (aeVal !== '') payload.Ae_mm2 = parseFloat(aeVal);
if (veVal !== '') payload.Ve_mm3 = parseFloat(veVal);
document.querySelectorAll('.winding-block').forEach(block => {
const name = block.querySelector('.winding-name').value.trim();
const taps = [0];
const awg = [];
block.querySelectorAll('.seg-row').forEach(row => {
taps.push(parseInt(row.querySelector('.seg-turns').value));
awg.push(parseInt(row.querySelector('.seg-awg').value));
});
payload.windings.push({ name, taps, awg });
});
return payload;
}
// ============================================================
// Design
// ============================================================
async function runDesign() {
const btn = document.getElementById('btn-design');
const msg = document.getElementById('design-msg');
const resContainer = document.getElementById('design-results-container');
setMsg(msg, 'info', 'Designing...');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Designing...';
resContainer.style.display = 'none';
try {
const payload = buildPayload();
const resp = await fetch('/api/design', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.success) {
setMsg(msg, 'error', 'Error: ' + data.error);
return;
}
setMsg(msg, 'ok', 'Design complete.');
showDrawing(data.drawing);
showDesignResults(data.windings);
resContainer.style.display = 'block';
} catch(e) {
setMsg(msg, 'error', 'Network error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Design Transformer';
}
}
function showDrawing(b64) {
const container = document.getElementById('drawing-container');
const img = document.getElementById('drawing-img');
container.innerHTML = '';
container.style.display = 'none';
img.src = b64;
img.style.display = 'block';
}
function showDesignResults(windings) {
const cont = document.getElementById('design-results-tables');
cont.innerHTML = '';
windings.forEach(w => {
const feasStr = w.feasible
? '<span class="badge-ok">OK</span>'
: '<span class="badge-bad">DOES NOT FIT</span>';
let html = `
<div style="margin-bottom:12px">
<strong>${w.name}</strong> — ${w.total_turns} turns total, ${w.total_wire_length_m.toFixed(2)} m,
${w.total_resistance_mohm.toFixed(1)} mΩ, ${w.total_weight_g.toFixed(2)} g — ${feasStr}
<table class="design-table" style="margin-top:6px">
<thead><tr>
<th>Tap</th><th>AWG</th><th>Turns</th>
<th>Wire (m)</th><th>R (mΩ)</th><th>Layers</th><th>Fit</th>
</tr></thead>
<tbody>`;
w.segments.forEach(seg => {
const layerSummary = seg.layers.map(lr =>
`L${lr.layer_index}: ${lr.turns_used}/${lr.turns_capacity}`
).join(', ');
const fitStr = seg.fits
? '<span class="badge-ok">OK</span>'
: '<span class="badge-bad">OVERFLOW</span>';
html += `<tr>
<td>${seg.tap_number}</td>
<td>${seg.awg}</td>
<td>${seg.turns}</td>
<td>${seg.wire_length_m.toFixed(3)}</td>
<td>${seg.resistance_mohm.toFixed(2)}</td>
<td style="font-size:11px">${layerSummary}</td>
<td>${fitStr}</td>
</tr>`;
});
html += '</tbody></table></div>';
cont.innerHTML += html;
});
}
// ============================================================
// Sweep (simulate)
// ============================================================
function parseFrequencies() {
const raw = document.getElementById('sim-freqs').value;
return raw.split(/[\n,]+/).map(s => s.trim()).filter(s => s !== '').map(Number).filter(v => v > 0);
}
function parseLoads() {
const raw = document.getElementById('sim-loads').value;
return raw.split('\n').map(s => s.trim()).filter(s => s !== '').map(line => {
const parts = line.split(',').map(Number);
return [parts[0] || 0, parts[1] || 0];
}).filter(p => p[0] > 0 || p[1] !== 0);
}
async function runSweep() {
const btn = document.getElementById('btn-sim');
const msg = document.getElementById('sim-msg');
const plotsCard = document.getElementById('plots-card');
setMsg(msg, 'info', 'Running simulation...');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Simulating...';
try {
const corePayload = buildPayload();
const freqs = parseFrequencies();
const loads = parseLoads();
if (freqs.length === 0) { setMsg(msg, 'error', 'No valid frequencies.'); return; }
if (loads.length === 0) { setMsg(msg, 'error', 'No valid loads.'); return; }
const payload = Object.assign({}, corePayload, {
frequencies: freqs,
loads: loads,
target_power_W: parseFloat(document.getElementById('sim-target').value),
power_tol_pct: parseFloat(document.getElementById('sim-tol').value),
Vp_min: parseFloat(document.getElementById('sim-vpmin').value),
Vp_max: parseFloat(document.getElementById('sim-vpmax').value),
Vp_steps: parseInt(document.getElementById('sim-vpsteps').value),
constraints: {
B_max_T: parseFloat(document.getElementById('con-B').value),
Vp_max: parseFloat(document.getElementById('con-Vp').value),
Vs_max: parseFloat(document.getElementById('con-Vs').value),
Ip_max: parseFloat(document.getElementById('con-Ip').value),
Is_max: parseFloat(document.getElementById('con-Is').value),
P_out_max_W: parseFloat(document.getElementById('con-Pout').value),
},
});
const resp = await fetch('/api/sweep', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.success) {
setMsg(msg, 'error', 'Error: ' + data.error);
return;
}
sweepData = data;
setMsg(msg, 'ok', `Done — ${data.rows.length} operating points computed.`);
// Populate frequency dropdown
const sel = document.getElementById('freq-select');
sel.innerHTML = '';
data.frequencies.forEach((f, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = f >= 1000 ? `${(f/1000).toFixed(1)} kHz` : `${f} Hz`;
sel.appendChild(opt);
});
plotsCard.style.display = 'block';
updatePlots();
} catch(e) {
setMsg(msg, 'error', 'Network error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Run Simulation';
}
}
// ============================================================
// Plots
// ============================================================
function updatePlots() {
if (!sweepData) return;
const freqIdx = parseInt(document.getElementById('freq-select').value);
const freq = sweepData.frequencies[freqIdx];
// Filter rows for this frequency, sort by load resistance
const rows = sweepData.rows
.filter(r => r.freq_hz === freq)
.sort((a, b) => a.Z_load_R - b.Z_load_R);
const freqLabel = freq >= 1000 ? `${(freq/1000).toFixed(1)} kHz` : `${freq} Hz`;
// Build single arrays — one point per load, using the optimizer's chosen taps
const x = rows.map(r => r.Z_load_R);
const Vs = rows.map(r => r.Vs_rms);
const Is = rows.map(r => r.Is_rms);
const Ip = rows.map(r => r.Ip_rms);
const Pout = rows.map(r => r.P_out_W);
const Pin = rows.map(r => r.P_in_W);
const Pcu = rows.map(r => r.P_cu_W);
const Pcore= rows.map(r => r.P_core_W);
const eff = rows.map(r => r.efficiency != null ? r.efficiency * 100 : null);
const Vp = rows.map(r => r.Vp_rms);
// Hover text: show the selected tap combination and Vp
const hoverTap = rows.map(r =>
r.primary_tap != null
? `P${r.primary_tap}/S${r.secondary_tap}<br>Vp=${r.Vp_rms != null ? r.Vp_rms.toFixed(2) : '?'}V`
: 'no solution'
);
const plotH = calcPlotHeight();
// --- Voltage plot ---
Plotly.react('plot-voltage', [
{ x, y: Vp, name: 'Vp', mode: 'lines+markers', type: 'scatter',
line: {dash: 'dot'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>Vp=%{y:.2f}V<br>%{text}<extra></extra>' },
{ x, y: Vs, name: 'Vs', mode: 'lines+markers', type: 'scatter',
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>Vs=%{y:.2f}V<br>%{text}<extra></extra>' },
], {
title: { text: `Voltage @ ${freqLabel}`, font: {size:13} },
xaxis: { title: 'R load (Ω)', type: 'log' },
yaxis: { title: 'V rms' },
margin: {t:36, b:42, l:54, r:10},
legend: {font:{size:11}},
height: plotH,
}, {responsive: true, displayModeBar: false});
// --- Current plot ---
Plotly.react('plot-current', [
{ x, y: Is, name: 'Is', mode: 'lines+markers', type: 'scatter',
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>Is=%{y:.4f}A<br>%{text}<extra></extra>' },
{ x, y: Ip, name: 'Ip', mode: 'lines+markers', type: 'scatter',
line: {dash: 'dot'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>Ip=%{y:.4f}A<br>%{text}<extra></extra>' },
], {
title: { text: `Current @ ${freqLabel}`, font: {size:13} },
xaxis: { title: 'R load (Ω)', type: 'log' },
yaxis: { title: 'I rms (A)' },
margin: {t:36, b:42, l:54, r:10},
legend: {font:{size:11}},
height: plotH,
}, {responsive: true, displayModeBar: false});
// --- Power plot ---
Plotly.react('plot-power', [
{ x, y: Pout, name: 'P out', mode: 'lines+markers', type: 'scatter',
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>P_out=%{y:.3f}W<br>%{text}<extra></extra>' },
{ x, y: Pin, name: 'P in', mode: 'lines+markers', type: 'scatter',
line: {dash:'dash'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>P_in=%{y:.3f}W<br>%{text}<extra></extra>' },
{ x, y: Pcu, name: 'P cu', mode: 'lines+markers', type: 'scatter',
line: {dash:'dot'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>P_cu=%{y:.3f}W<br>%{text}<extra></extra>' },
{ x, y: eff, name: 'Eff %', mode: 'lines+markers', type: 'scatter',
yaxis: 'y2', line: {dash:'dot'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>Eff=%{y:.1f}%<br>%{text}<extra></extra>' },
], {
title: { text: `Power & Efficiency @ ${freqLabel}`, font: {size:13} },
xaxis: { title: 'R load (Ω)', type: 'log' },
yaxis: { title: 'Power (W)' },
yaxis2: { title: 'Efficiency (%)', overlaying: 'y', side: 'right', range: [0, 105] },
margin: {t:36, b:42, l:54, r:55},
legend: {font:{size:11}},
height: plotH,
}, {responsive: true, displayModeBar: false});
document.getElementById('plot-status').textContent =
`${rows.length} points, ${rows.filter(r => r.met_target).length} met target`;
updateTable(rows);
}
// ============================================================
// Results table (CSV-equivalent, all rows for selected freq)
// ============================================================
const _CSV_COLS = [
{ key: 'freq_hz', label: 'freq (Hz)' },
{ key: 'Z_load_R', label: 'R (Ω)' },
{ key: 'Z_load_X', label: 'X (Ω)' },
{ key: 'target_power_W', label: 'target (W)' },
{ key: 'met_target', label: 'met' },
{ key: 'power_error_pct', label: 'err (%)' },
{ key: 'primary_tap', label: 'P tap' },
{ key: 'secondary_tap', label: 'S tap' },
{ key: 'Vp_rms', label: 'Vp (V)' },
{ key: 'Np_eff', label: 'Np' },
{ key: 'Ns_eff', label: 'Ns' },
{ key: 'turns_ratio', label: 'ratio' },
{ key: 'B_peak_T', label: 'B (T)' },
{ key: 'Vs_rms', label: 'Vs (V)' },
{ key: 'Ip_rms', label: 'Ip (A)' },
{ key: 'Is_rms', label: 'Is (A)' },
{ key: 'load_phase_deg', label: 'phase (°)' },
{ key: 'Rp_ohm', label: 'Rp (Ω)' },
{ key: 'Rs_ohm', label: 'Rs (Ω)' },
{ key: 'P_out_W', label: 'P_out (W)' },
{ key: 'P_cu_W', label: 'P_cu (W)' },
{ key: 'P_cu_primary_W', label: 'P_cu_p (W)' },
{ key: 'P_cu_secondary_W', label: 'P_cu_s (W)' },
{ key: 'P_core_W', label: 'P_core (W)' },
{ key: 'P_in_W', label: 'P_in (W)' },
{ key: 'efficiency', label: 'eff' },
];
function fmtCell(key, val) {
if (val === null || val === undefined) return '<span class="cell-null">—</span>';
if (key === 'met_target') {
return val
? '<span class="cell-ok">YES</span>'
: '<span class="cell-bad">NO</span>';
}
if (key === 'efficiency' && typeof val === 'number') {
return (val * 100).toFixed(2) + '%';
}
if (typeof val === 'number') {
// integers: no decimals; floats: 2 decimal places
return Number.isInteger(val) ? val : val.toFixed(2);
}
return val;
}
function updateTable(rows) {
document.getElementById('table-card').style.display = 'block';
const thead = document.getElementById('sweep-table-head');
const tbody = document.getElementById('sweep-table-body');
thead.innerHTML = '<tr>' + _CSV_COLS.map(c => `<th>${c.label}</th>`).join('') + '</tr>';
tbody.innerHTML = rows.map(r =>
'<tr>' + _CSV_COLS.map(c => `<td>${fmtCell(c.key, r[c.key])}</td>`).join('') + '</tr>'
).join('');
}
function triggerCsvDownload() {
if (!sweepData) return false;
const cols = _CSV_COLS.map(c => c.key);
const header = _CSV_COLS.map(c => c.label).join(',');
const lines = sweepData.rows.map(r =>
cols.map(k => {
const v = r[k];
if (v === null || v === undefined) return '';
if (k === 'efficiency' && typeof v === 'number') return (v * 100).toFixed(4);
if (typeof v === 'number') return Number.isInteger(v) ? v : v.toFixed(4);
return v;
}).join(',')
);
const csv = [header, ...lines].join('\r\n');
const blob = new Blob([csv], {type: 'text/csv'});
const url = URL.createObjectURL(blob);
const a = document.getElementById('csv-download');
a.href = url;
// Let the browser follow the href naturally for download
setTimeout(() => URL.revokeObjectURL(url), 5000);
return true; // allow default href navigation (triggers download)
}
// ============================================================
// Helpers
// ============================================================
function setMsg(el, type, text) {
el.className = 'msg visible msg-' + type;
el.textContent = text;
}
// Calculate per-plot height.
// Uses an explicit override when set (e.g. from a manual resize), otherwise 300px default.
let _plotHeightOverride = null;
function calcPlotHeight() {
return _plotHeightOverride !== null ? _plotHeightOverride : 300;
}
// Re-render plots when the user manually drags the card taller.
// We detect manual resize by watching card.style.height (set by the browser's
// resize handle), NOT clientHeight (which changes when plot content grows).
(function setupResizeObserver() {
const card = document.getElementById('plots-card');
if (!card || typeof ResizeObserver === 'undefined') return;
let rafId = null;
const ro = new ResizeObserver(() => {
// Only act when the inline style height is set (i.e. user dragged the handle)
const styleH = parseInt(card.style.height);
if (!styleH || isNaN(styleH)) return;
// Derive plot height from the dragged card height
const newPlotH = Math.max(220, styleH - 84);
if (newPlotH === _plotHeightOverride) return;
_plotHeightOverride = newPlotH;
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
if (sweepData) updatePlots();
});
});
ro.observe(card);
})();
// ============================================================
// Initialise with default windings
// ============================================================
addWinding('primary', [25, 50], [22, 22]);
addWinding('secondary', [100, 50, 50, 50], [22, 22, 22, 26]);
</script>
</body>
</html>