Files
toroid/templates/index.html
Brent Perteet 9521e0876d Add Docker/Compose setup and fix subpath routing for /toroid deployment
- Add Dockerfile, .dockerignore, and docker-compose.yml to containerize the app
- Add toroid.service systemd unit (uses docker compose)
- Mount presets/ as a bind volume for persistence outside the container
- Fix all fetch() calls in templates to use relative paths (no leading /)
  so they resolve correctly when served under /toroid/ via nginx proxy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 23:26:27 +00:00

1556 lines
59 KiB
HTML
Raw Permalink 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; }
/* ---- Preset bar ---- */
.preset-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: #f1f5f9;
border-bottom: 1px solid #e2e8f0;
flex-wrap: wrap;
}
.preset-bar select {
flex: 1;
min-width: 100px;
max-width: 200px;
font-size: 12px;
padding: 3px 6px;
}
.preset-bar input[type=text] {
flex: 1;
min-width: 80px;
max-width: 160px;
font-size: 12px;
padding: 3px 6px;
}
.btn-preset {
padding: 3px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
}
.btn-preset-load { background: #dbeafe; color: #1d4ed8; }
.btn-preset-load:hover { background: #bfdbfe; }
.btn-preset-save { background: #dcfce7; color: #166534; }
.btn-preset-save:hover { background: #bbf7d0; }
.btn-preset-delete { background: #fee2e2; color: #b91c1c; }
.btn-preset-delete:hover { background: #fca5a5; }
/* ---- 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; }
/* Constraint proximity highlights — override row stripes */
.sweep-table td.cell-limit-met { background: #dcfce7 !important; } /* green — met target */
.sweep-table td.cell-limit-near { background: #fef9c3 !important; } /* yellow — near limit */
.sweep-table td.cell-limit-over { background: #fed7aa !important; } /* orange — over limit */
/* ---- 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 !important; }
}
</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="preset-bar">
<select id="preset-sel-core" title="Saved cores"></select>
<button class="btn-preset btn-preset-load" onclick="presetLoad('core')">Load</button>
<input type="text" id="preset-name-core" placeholder="preset name">
<button class="btn-preset btn-preset-save" onclick="presetSave('core')">Save</button>
<button class="btn-preset btn-preset-delete" onclick="presetDelete('core')">Delete</button>
</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="preset-bar">
<select id="preset-sel-windings" title="Saved windings"></select>
<button class="btn-preset btn-preset-load" onclick="presetLoad('windings')">Load</button>
<input type="text" id="preset-name-windings" placeholder="preset name">
<button class="btn-preset btn-preset-save" onclick="presetSave('windings')">Save</button>
<button class="btn-preset btn-preset-delete" onclick="presetDelete('windings')">Delete</button>
</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="preset-bar">
<select id="preset-sel-sim" title="Saved sim settings"></select>
<button class="btn-preset btn-preset-load" onclick="presetLoad('sim')">Load</button>
<input type="text" id="preset-name-sim" placeholder="preset name">
<button class="btn-preset btn-preset-save" onclick="presetSave('sim')">Save</button>
<button class="btn-preset btn-preset-delete" onclick="presetDelete('sim')">Delete</button>
</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 class="preset-bar" style="margin:6px 0; padding:4px 0; background:none; border:none">
<select id="preset-sel-constraints" title="Saved constraints"></select>
<button class="btn-preset btn-preset-load" onclick="presetLoad('constraints')">Load</button>
<input type="text" id="preset-name-constraints" placeholder="preset name">
<button class="btn-preset btn-preset-save" onclick="presetSave('constraints')">Save</button>
<button class="btn-preset btn-preset-delete" onclick="presetDelete('constraints')">Delete</button>
</div>
<div style="margin-top:4px">
<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="form-row" style="margin-top:8px">
<label class="field">
<span>Ambient temp (°C)</span>
<input type="number" id="sim-tambient" value="25" min="-40" max="100" step="1">
</label>
<label class="field">
<span>Conv. coeff h (W/m²K)</span>
<input type="number" id="sim-hconv" value="6" min="1" max="100" step="0.5"
title="Still-air natural convection ≈ 510 W/m²K">
</label>
</div>
<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>
<!-- Operating Point -->
<div class="card">
<div class="card-header">Operating Point</div>
<div class="card-body">
<div class="form-row">
<label class="field"><span>Primary tap</span><input type="number" id="op-ptap" value="1" min="1" step="1"></label>
<label class="field"><span>Secondary tap</span><input type="number" id="op-stap" value="1" min="1" step="1"></label>
</div>
<div class="form-row">
<label class="field"><span>Vp RMS (V)</span><input type="number" id="op-vp" value="12" min="0.01" step="0.1"></label>
<label class="field"><span>Frequency (Hz)</span><input type="number" id="op-freq" value="1000" min="1" step="1"></label>
</div>
<div class="form-row">
<label class="field"><span>Load R (Ω)</span><input type="number" id="op-rload" value="100" min="0" step="1"></label>
<label class="field"><span>Load X (Ω)</span><input type="number" id="op-xload" value="0" step="1"></label>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-op" onclick="runOperatingPoint()">Simulate</button>
</div>
<div id="op-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>
<!-- Operating Point Results -->
<div class="card" id="op-results-card" style="display:none">
<div class="card-header">Operating Point Results</div>
<div class="card-body" style="padding:12px">
<div id="op-violations" style="display:none;margin-bottom:10px;padding:8px 12px;background:#fef2f2;border:1px solid #fca5a5;border-radius:6px;color:#b91c1c;font-size:12px"></div>
<table class="design-table" style="width:100%">
<tbody id="op-results-body"></tbody>
</table>
</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" style="grid-template-columns: 1fr 1fr;">
<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 class="plot-box" id="plot-temp"></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 style="padding:6px 14px 10px;font-size:11px;color:#64748b;display:flex;gap:16px;align-items:center">
<span>P_out shading:</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#dcfce7;border:1px solid #86efac;vertical-align:middle;margin-right:4px"></span>Met target</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#fef9c3;border:1px solid #d4b800;vertical-align:middle;margin-right:4px"></span>Near limit (&gt;90%)</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#fed7aa;border:1px solid #c2590a;vertical-align:middle;margin-right:4px"></span>Over limit</span>
<span style="color:#94a3b8">Near/over applies to: B, Vs, Ip, Is, P_out</span>
</div>
</div>
</div><!-- /right-col -->
</div><!-- /layout -->
<script>
// ============================================================
// State
// ============================================================
let windingCount = 0;
let sweepData = null; // last successful sweep result
let designSurfaceArea = null; // A_surface_m2 from last design run
// ============================================================
// 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.');
designSurfaceArea = data.A_surface_m2 || null;
showDrawing(data.drawing);
showDesignResults(data.windings, data.fill_factor_target, data.fill_factor_actual, data.window_area_mm2, data.A_surface_m2);
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, fillTarget, fillActual, windowArea, surfaceArea) {
const cont = document.getElementById('design-results-tables');
cont.innerHTML = '';
// Fill factor summary row
if (fillActual != null) {
const pct = (fillActual * 100).toFixed(1);
const tgtPct = fillTarget != null ? (fillTarget * 100).toFixed(0) : '?';
const over = fillActual > fillTarget * 1.005;
const color = over ? '#b91c1c' : '#166534';
const surfStr = surfaceArea != null
? `&nbsp;|&nbsp; Surface area: <strong>${(surfaceArea * 1e4).toFixed(1)} cm²</strong>` : '';
cont.innerHTML += `
<div style="margin-bottom:10px;padding:6px 10px;background:#f1f5f9;border-radius:6px;font-size:12px">
Window area: <strong>${windowArea != null ? windowArea.toFixed(1) : '?'} mm²</strong>
&nbsp;|&nbsp;
Target fill: <strong>${tgtPct}%</strong>
&nbsp;|&nbsp;
Actual fill: <strong style="color:${color}">${pct}%</strong>
${surfStr}
</div>`;
}
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);
}
// ============================================================
// Operating Point
// ============================================================
async function runOperatingPoint() {
const btn = document.getElementById('btn-op');
const msg = document.getElementById('op-msg');
const card = document.getElementById('op-results-card');
btn.disabled = true;
setMsg(msg, 'info', 'Simulating…');
try {
const payload = Object.assign({}, buildPayload(), {
primary_tap: parseInt(document.getElementById('op-ptap').value),
secondary_tap: parseInt(document.getElementById('op-stap').value),
Vp_rms: parseFloat(document.getElementById('op-vp').value),
freq_hz: parseFloat(document.getElementById('op-freq').value),
R_load: parseFloat(document.getElementById('op-rload').value) || 0,
X_load: parseFloat(document.getElementById('op-xload').value) || 0,
constraints: collectConstraints(),
});
const resp = await fetch('api/simulate', {
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', 'Done.');
card.style.display = '';
// Violations banner
const vdiv = document.getElementById('op-violations');
if (data.violations && data.violations.length) {
vdiv.style.display = '';
vdiv.textContent = 'Violations: ' + data.violations.join(', ');
} else {
vdiv.style.display = 'none';
}
const rows = [
['Turns (Np / Ns)', `${data.Np_eff} / ${data.Ns_eff}`],
['Turns ratio (Np:Ns)', data.turns_ratio != null ? data.turns_ratio.toFixed(4) : '—'],
['Peak flux density', data.B_peak_T != null ? data.B_peak_T.toFixed(4) + ' T' : '—'],
['Primary voltage (Vp)', data.Vp_rms != null ? data.Vp_rms.toFixed(3) + ' V' : '—'],
['Secondary voltage (Vs)', data.Vs_rms != null ? data.Vs_rms.toFixed(3) + ' V' : '—'],
['Primary current (Ip)', data.Ip_rms != null ? data.Ip_rms.toFixed(4) + ' A' : '—'],
['Secondary current (Is)', data.Is_rms != null ? data.Is_rms.toFixed(4) + ' A' : '—'],
['Output power', data.P_out_W != null ? data.P_out_W.toFixed(3) + ' W' : '—'],
['Copper loss (primary)', data.P_cu_primary_W != null ? data.P_cu_primary_W.toFixed(3) + ' W' : '—'],
['Copper loss (secondary)', data.P_cu_secondary_W != null ? data.P_cu_secondary_W.toFixed(3) + ' W' : '—'],
['Total copper loss', data.P_cu_W != null ? data.P_cu_W.toFixed(3) + ' W' : '—'],
['Core loss', data.P_core_W != null ? data.P_core_W.toFixed(3) + ' W' : '—'],
['Input power', data.P_in_W != null ? data.P_in_W.toFixed(3) + ' W' : '—'],
['Efficiency', data.efficiency_pct != null ? data.efficiency_pct.toFixed(2) + ' %' : '—'],
];
const tbody = document.getElementById('op-results-body');
tbody.innerHTML = rows.map(([label, val]) =>
`<tr><td style="font-weight:500;color:#444;width:55%">${label}</td><td>${val}</td></tr>`
).join('');
} catch (err) {
setMsg(msg, 'error', 'Error: ' + err.message);
} finally {
btn.disabled = false;
}
}
// Helper: collect constraints object from UI
function collectConstraints() {
return {
B_max_T: parseFloat(document.getElementById('con-B').value) || 0.3,
Vp_max: parseFloat(document.getElementById('con-Vp').value) || 1e9,
Vs_max: parseFloat(document.getElementById('con-Vs').value) || 1e9,
Ip_max: parseFloat(document.getElementById('con-Ip').value) || 1e9,
Is_max: parseFloat(document.getElementById('con-Is').value) || 1e9,
P_out_max_W:parseFloat(document.getElementById('con-Pout').value) || 1e9,
};
}
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;
// Stash constraint limits so updateTable can shade near-limit cells
sweepData._constraints = {
B_max_T: parseFloat(document.getElementById('con-B').value) || Infinity,
Vs_max: parseFloat(document.getElementById('con-Vs').value) || Infinity,
Ip_max: parseFloat(document.getElementById('con-Ip').value) || Infinity,
Is_max: parseFloat(document.getElementById('con-Is').value) || Infinity,
P_out_max_W: parseFloat(document.getElementById('con-Pout').value) || Infinity,
};
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`;
// --- Temperature plot ---
const T_amb = parseFloat(document.getElementById('sim-tambient').value) || 25;
const h_conv = parseFloat(document.getElementById('sim-hconv').value) || 6;
const A_surf = designSurfaceArea; // m² from last design, null if not run yet
const T_rise = rows.map(r => {
if (r.P_in_W == null || r.P_out_W == null || !A_surf) return null;
return (r.P_in_W - r.P_out_W) / (h_conv * A_surf);
});
const T_copper = T_rise.map(dt => dt != null ? T_amb + dt : null);
Plotly.react('plot-temp', [
{ x, y: T_rise, name: 'T rise (°C)', mode: 'lines+markers', type: 'scatter',
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>ΔT=%{y:.1f}°C<br>%{text}<extra></extra>' },
{ x, y: T_copper, name: 'T copper (°C)', mode: 'lines+markers', type: 'scatter',
line: {dash: 'dot'},
text: hoverTap, hovertemplate: 'R=%{x}Ω<br>T=%{y:.1f}°C<br>%{text}<extra></extra>' },
], {
title: { text: `Temperature @ ${freqLabel} (T_amb=${T_amb}°C, h=${h_conv} W/m²K)`, font: {size:12} },
xaxis: { title: 'R load (Ω)', type: 'log' },
yaxis: { title: 'Temperature (°C)' },
margin: {t:40, b:42, l:54, r:10},
legend: {font:{size:11}},
height: plotH,
}, {responsive: true, displayModeBar: false});
updateTable(rows, T_rise, T_copper);
}
// ============================================================
// Results table (CSV-equivalent, all rows for selected freq)
// ============================================================
// All fields — used for CSV export only
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' },
];
// Columns shown in the on-screen table (subset + computed P_loss)
const _TABLE_COLS = [
{ key: 'freq_hz', label: 'freq (Hz)' },
{ key: 'Z_load_R', label: 'R (Ω)' },
{ key: 'Z_load_X', label: 'X (Ω)' },
{ 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: '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: 'P_in_W', label: 'P_in (W)' },
{ key: 'P_out_W', label: 'P_out (W)' },
{ key: '_P_loss', label: 'P_loss (W)' }, // computed
{ key: 'efficiency', label: 'eff' },
{ key: '_T_rise', label: 'ΔT (°C)' }, // computed from thermal model
{ key: '_T_copper', label: 'T_cu (°C)' }, // computed
];
function fmtCell(key, val, row) {
if (key === '_P_loss') {
const pin = row && row.P_in_W != null ? row.P_in_W : null;
const pout = row && row.P_out_W != null ? row.P_out_W : null;
val = (pin != null && pout != null) ? pin - pout : null;
}
if (key === '_T_rise') { val = row ? row._T_rise : null; }
if (key === '_T_copper') { val = row ? row._T_copper : null; }
if (val === null || val === undefined) return '<span class="cell-null">—</span>';
if (key === 'efficiency' && typeof val === 'number') {
return (val * 100).toFixed(2) + '%';
}
if (typeof val === 'number') {
return Number.isInteger(val) ? val : val.toFixed(2);
}
return val;
}
/**
* Return an object mapping column key → 'over' | 'near' | null for a row,
* based on how close each value is to its constraint limit.
*
* 'over' = value exceeds the limit
* 'near' = value is within NEAR_THRESHOLD of the limit (ratio > 0.9)
* null = fine
*/
const _LIMIT_NEAR_THRESHOLD = 0.90; // flag cell when value/limit > this
function limitingCells(row, constraints) {
if (!constraints) return {};
const result = {};
function check(colKey, value, limit) {
if (value == null || !isFinite(limit) || limit <= 0) return;
const ratio = value / limit;
if (ratio > 1.0) result[colKey] = 'over';
else if (ratio > _LIMIT_NEAR_THRESHOLD) result[colKey] = 'near';
}
check('B_peak_T', row.B_peak_T, constraints.B_max_T);
check('Vs_rms', row.Vs_rms, constraints.Vs_max);
check('Ip_rms', row.Ip_rms, constraints.Ip_max);
check('Is_rms', row.Is_rms, constraints.Is_max);
check('P_out_W', row.P_out_W, constraints.P_out_max_W);
return result;
}
function updateTable(rows, T_rise, T_copper) {
document.getElementById('table-card').style.display = 'block';
// Attach computed thermal values directly onto each row object
rows.forEach((r, i) => {
r._T_rise = T_rise ? T_rise[i] : null;
r._T_copper = T_copper ? T_copper[i] : null;
});
const thead = document.getElementById('sweep-table-head');
const tbody = document.getElementById('sweep-table-body');
const constraints = sweepData && sweepData._constraints;
thead.innerHTML = '<tr>' + _TABLE_COLS.map(c => `<th>${c.label}</th>`).join('') + '</tr>';
tbody.innerHTML = rows.map(r => {
const limits = limitingCells(r, constraints);
const cells = _TABLE_COLS.map(c => {
const lvl = limits[c.key];
// P_out_W gets green when the optimizer met its target (and isn't near/over a limit)
const metGreen = c.key === 'P_out_W' && r.met_target && !lvl;
const cls = lvl === 'over' ? ' class="cell-limit-over"'
: lvl === 'near' ? ' class="cell-limit-near"'
: metGreen ? ' class="cell-limit-met"'
: '';
return `<td${cls}>${fmtCell(c.key, r[c.key], r)}</td>`;
});
return '<tr>' + cells.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);
})();
// ============================================================
// Preset system
// ============================================================
// Serialise current UI state for each preset type
function presetExtract(ptype) {
if (ptype === 'core') {
const d = {
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),
};
const ae = document.getElementById('core-Ae').value.trim();
const ve = document.getElementById('core-Ve').value.trim();
if (ae !== '') d.Ae_mm2 = parseFloat(ae);
if (ve !== '') d.Ve_mm3 = parseFloat(ve);
return d;
}
if (ptype === 'windings') {
const windings = [];
document.querySelectorAll('.winding-block').forEach(block => {
const name = block.querySelector('.winding-name').value.trim();
const taps = [0], awg = [];
block.querySelectorAll('.seg-row').forEach(row => {
taps.push(parseInt(row.querySelector('.seg-turns').value));
awg.push(parseInt(row.querySelector('.seg-awg').value));
});
windings.push({ name, taps, awg });
});
return { windings };
}
if (ptype === 'sim') {
return {
frequencies: document.getElementById('sim-freqs').value,
loads: document.getElementById('sim-loads').value,
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),
T_ambient_C: parseFloat(document.getElementById('sim-tambient').value),
h_conv: parseFloat(document.getElementById('sim-hconv').value),
};
}
if (ptype === 'constraints') {
return {
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),
};
}
}
// Apply loaded preset data back to the UI
function presetApply(ptype, data) {
if (ptype === 'core') {
document.getElementById('core-ID').value = data.ID_mm ?? '';
document.getElementById('core-OD').value = data.OD_mm ?? '';
document.getElementById('core-H').value = data.height_mm ?? '';
document.getElementById('core-fill').value = data.fill_factor ?? 0.35;
document.getElementById('core-Ae').value = data.Ae_mm2 ?? '';
document.getElementById('core-Ve').value = data.Ve_mm3 ?? '';
}
if (ptype === 'windings') {
const container = document.getElementById('windings-container');
container.innerHTML = '';
windingCount = 0;
(data.windings || []).forEach(w => {
addWinding(w.name, w.taps.slice(1), w.awg);
});
}
if (ptype === 'sim') {
if (data.frequencies !== undefined) document.getElementById('sim-freqs').value = data.frequencies;
if (data.loads !== undefined) document.getElementById('sim-loads').value = data.loads;
if (data.target_power_W !== undefined) document.getElementById('sim-target').value = data.target_power_W;
if (data.power_tol_pct !== undefined) document.getElementById('sim-tol').value = data.power_tol_pct;
if (data.Vp_min !== undefined) document.getElementById('sim-vpmin').value = data.Vp_min;
if (data.Vp_max !== undefined) document.getElementById('sim-vpmax').value = data.Vp_max;
if (data.Vp_steps !== undefined) document.getElementById('sim-vpsteps').value = data.Vp_steps;
if (data.T_ambient_C !== undefined) document.getElementById('sim-tambient').value = data.T_ambient_C;
if (data.h_conv !== undefined) document.getElementById('sim-hconv').value = data.h_conv;
}
if (ptype === 'constraints') {
if (data.B_max_T !== undefined) document.getElementById('con-B').value = data.B_max_T;
if (data.Vp_max !== undefined) document.getElementById('con-Vp').value = data.Vp_max;
if (data.Vs_max !== undefined) document.getElementById('con-Vs').value = data.Vs_max;
if (data.Ip_max !== undefined) document.getElementById('con-Ip').value = data.Ip_max;
if (data.Is_max !== undefined) document.getElementById('con-Is').value = data.Is_max;
if (data.P_out_max_W !== undefined) document.getElementById('con-Pout').value = data.P_out_max_W;
}
}
// Populate a preset dropdown from server
async function presetRefresh(ptype) {
const sel = document.getElementById(`preset-sel-${ptype}`);
if (!sel) return null;
const resp = await fetch(`api/presets/${ptype}`);
const data = await resp.json();
if (!data.success) return null;
sel.innerHTML = data.names.length === 0
? '<option value="">(no saved presets)</option>'
: data.names.map(n => `<option value="${n}"${n === data.last ? ' selected' : ''}>${n}</option>`).join('');
// Sync name input to selected
const nameEl = document.getElementById(`preset-name-${ptype}`);
if (nameEl && data.last) nameEl.value = data.last;
return data;
}
async function presetLoad(ptype) {
const sel = document.getElementById(`preset-sel-${ptype}`);
const name = sel ? sel.value : '';
if (!name) return;
const resp = await fetch(`api/presets/${ptype}/${encodeURIComponent(name)}`);
const data = await resp.json();
if (!data.success) { alert('Load failed: ' + data.error); return; }
presetApply(ptype, data.data);
// Sync name input
const nameEl = document.getElementById(`preset-name-${ptype}`);
if (nameEl) nameEl.value = name;
}
async function presetSave(ptype) {
const nameEl = document.getElementById(`preset-name-${ptype}`);
const name = nameEl ? nameEl.value.trim() : '';
if (!name) { alert('Enter a preset name first.'); return; }
const payload = presetExtract(ptype);
const resp = await fetch(`api/presets/${ptype}/${encodeURIComponent(name)}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!data.success) { alert('Save failed: ' + data.error); return; }
await presetRefresh(ptype);
// Select the newly saved name
const sel = document.getElementById(`preset-sel-${ptype}`);
if (sel) sel.value = name;
}
async function presetDelete(ptype) {
const sel = document.getElementById(`preset-sel-${ptype}`);
const name = sel ? sel.value : '';
if (!name || name === '(no saved presets)') return;
if (!confirm(`Delete preset "${name}"?`)) return;
const resp = await fetch(`api/presets/${ptype}/${encodeURIComponent(name)}`, { method: 'DELETE' });
const data = await resp.json();
if (!data.success) { alert('Delete failed: ' + data.error); return; }
await presetRefresh(ptype);
}
// On page load: refresh all dropdowns and auto-load last-used preset for each type
async function initPresets() {
for (const ptype of ['core', 'windings', 'sim', 'constraints']) {
const info = await presetRefresh(ptype);
if (info && info.last) {
// Auto-load last used
const resp = await fetch(`api/presets/${ptype}/${encodeURIComponent(info.last)}`);
const d = await resp.json();
if (d.success) presetApply(ptype, d.data);
}
}
}
// ============================================================
// Initialise: load presets first; fall back to hardcoded defaults
// ============================================================
(async () => {
// initPresets returns after applying last-used presets (if any)
const windingsInfo = await (async () => {
const r = await fetch('api/presets/windings');
const d = await r.json();
return d.success ? d : null;
})();
// Only add default windings if no saved windings preset exists
if (!windingsInfo || !windingsInfo.last) {
addWinding('primary', [25, 50], [22, 22]);
addWinding('secondary', [100, 50, 50, 50], [22, 22, 22, 26]);
}
await initPresets();
})();
</script>
</body>
</html>