1035 lines
35 KiB
HTML
1035 lines
35 KiB
HTML
<!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()">↓ 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>
|