-
Progress
-
-
0%
+
+
+
+
+
+
+
+
CAD workspace
+
Import DXF files, assign layers, and preview combined geometry.
+
+
DXF tooling
+
+
+
DXF quickstart
+
+ - Upload domain and structural references first, then material regions, magnets, and finally the wire layers.
+ - Use multiple files when CAD exports separate stators, rotors, or wiring harnesses—each file can be mapped independently.
+ -
+ Need a walkthrough? Follow the
+ induction motor
+ or
+ iron ring
+ workflows.
+
+
+
+
+ {% if dxf_notice %}
+
{{ dxf_notice }}
+ {% endif %}
+
+
+
Layer assignments
+ {% if dxf_files %}
+ {% for file in dxf_files %}
+
+
+
+
+
{{ file.name }}
+ {% if file.uploaded_label %}
+ Imported {{ file.uploaded_label }}
+ {% endif %}
+
+
+
+
+
+
+ {% endfor %}
+ {% else %}
+
No DXF files have been imported yet. Upload CAD geometry to begin mapping layers.
+ {% endif %}
+
+
+
Combined preview
+ {% set cad_preview_src = dxf_preview_url %}
+ {% if cad_preview_src and dxf_preview_token %}
+ {% if '?' in cad_preview_src %}
+ {% set cad_preview_src = cad_preview_src ~ '&t=' ~ (dxf_preview_token|int) %}
+ {% else %}
+ {% set cad_preview_src = cad_preview_src ~ '?t=' ~ (dxf_preview_token|int) %}
+ {% endif %}
+ {% endif %}
+ {% if cad_preview_src %}
+

+ {% elif has_dxf_selection %}
+
Selected layers have no drawable geometry yet. Adjust the mapping or source files.
+ {% else %}
+
Select layers and save the mapping to generate a combined preview.
+ {% endif %}
+
+
+
-
-
-
+
+
+
+
+
+
+
+
Materials & regions
+
Define the permeability palette that powers the scenario.
+
+
Scenario editor
+
+
Each entry maps to a materials block inside the scenario JSON. Use descriptive names (air, rotor_steel, PM) so layer assignments and region scripts can reference them clearly.
+
+
How the palette is used
+
+ - The first entry typically stays as air so uniform regions always resolve.
+ - Additional entries feed the DXF layer categories — when you label a DXF layer as a Material region, the drop-down lists the names you define here.
+ - Update and save the palette any time you change permeability data; the scenario JSON is rewritten automatically.
+
+
+ {% if not project_has_scenario %}
+
Upload or create a scenario before editing materials.
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
Windings & conductor mapping
+
Group DXF wire layers into named phases and automatically generate current_region sources.
+
+
Sources
+
+
Assign the conductor layers exported from CAD to logical windings (Phase A, B, C, excitation coils, etc.). Each selected layer is rasterised into a current_region with the turn count and fill fraction you specify.
+
+
Winding layout tips
+
+ - Mark DXF layers that contain copper as Conductor path in the CAD workspace. Only those layers appear in the multi-selects below.
+ - Create one row per phase or coil group (e.g. Phase A, Phase B, Excitation). Use the orientation pickers to flip into/out of the page.
+ - Hold Ctrl/Cmd to assign multiple layers to the same winding when you split coils across uploads.
+ - Click Apply windings to rewrite the scenario's
sources array; the File menu “Save scenario…” action exports the updated JSON.
+
+
+ {% if not project_has_scenario %}
+
Upload a scenario before configuring windings.
+ {% elif not wire_layer_options %}
+
Mark DXF layers as Conductor path in the CAD workspace to unlock the winding builder.
+ {% else %}
+
+
+ {% for option in wire_layer_options %}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
Stimulus & timeline designer
+
Create balanced three-phase waveforms or import custom JSON timelines.
+
+
Transient controls
+
+
Balanced mode synthesises sinusoidal phase currents for you—just set the amplitude, frequency, and number of steps. Manual mode accepts the same JSON structure as the solver ({"t": 0.0, "phase_currents": {"A": 10}}).
+
+
Timeline workflow
+
+ - Balanced mode emits evenly spaced frames with three-phase sinusoids. Adjust the sequence string (ABC, ACB, etc.) to match your winding order.
+ - Manual mode is ideal for arbitrary drive patterns. Paste the JSON frames directly from an external script or spreadsheet.
+ - The solver's
transient.dt and transient.n_steps fields update automatically so the CLI run matches the generated timeline.
+ - Link back to the iron ring walkthrough for a concrete example.
+
+
+ {% if not project_has_scenario %}
+
Upload a scenario to enable timeline editing.
+ {% endif %}
+
+ {% if timeline_summary %}
+
+ {{ timeline_summary.frames }} frames • {{ '{:.4f}'.format(timeline_summary.duration) }} s total duration
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
Geometry preview
+
Preview the domain layout before launching a solve.
+
+
Server rendered
+
+ {% if preview_error %}
+
{{ preview_error }}
+ {% endif %}
+ {% if preview_notice %}
+
{{ preview_notice }}
+ {% endif %}
+
+ Upload or reuse a scenario and choose Preview geometry to render the layout.
+
+ {% set preview_src = preview_url %}
+ {% if preview_src and preview_token %}
+ {% if '?' in preview_src %}
+ {% set preview_src = preview_src ~ '&t=' ~ (preview_token|int) %}
+ {% else %}
+ {% set preview_src = preview_src ~ '?t=' ~ (preview_token|int) %}
+ {% endif %}
+ {% endif %}
+

+
+
+
+
+
+
+
+
+
+
+
Simulation setup
+
Upload scenarios, adjust solver settings, and manage run controls.
+
+
Stage 1 core
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
Need to edit the scenario?
+ Use the
Materials,
Windings, and
Timeline sections above to compose the JSON graphically, then save or run it from here.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Results visualisation
+
Adjust overlays to explore the simulated field.
+
+
+
+
+ {{ visualization_message or 'Run a simulation to generate field visualisations.' }}
+
+

+
{{ last_visualization_caption }}
+
+
+
+
+
+
+
+
{% endblock %}
{% block scripts %}
@@ -73,8 +667,486 @@
Downloads
const logArea = document.getElementById("log-area");
const resultsContainer = document.getElementById("results");
const resultsList = document.getElementById("results-list");
+ const visualizationPanel = document.getElementById("visualization-panel");
+ const visualizationMessage = document.getElementById("visualization-message");
+ const visualizationCaption = document.getElementById("visualization-caption");
+ const visualizationError = document.getElementById("visualization-error");
+ const resultImage = document.getElementById("result-image");
+ const updateVizBtn = document.getElementById("update-visualization");
+ const vizForm = document.getElementById("visualization-form");
+ const vizControls = document.getElementById("visualization-controls");
+ const vizPlayBtn = document.getElementById("viz-play");
+ const vizPrevBtn = document.getElementById("viz-prev");
+ const vizNextBtn = document.getElementById("viz-next");
+ const vizSlider = document.getElementById("viz-slider");
+ const vizFrameLabel = document.getElementById("viz-frame-label");
+ const materialsForm = document.getElementById("materials-form");
+ const materialsRows = document.getElementById("materials-rows");
+ const materialsCountInput = document.getElementById("materials-count");
+ const addMaterialBtn = document.getElementById("add-material-row");
+ const windingsForm = document.getElementById("windings-form");
+ const windingsRows = document.getElementById("windings-rows");
+ const windingsCountInput = document.getElementById("winding-count");
+ const addWindingBtn = document.getElementById("add-winding-row");
+ const layerOptionsTemplate = document.getElementById("wire-layer-options-template");
+ const timelineModeSelect = document.getElementById("timeline-mode");
+ const manualTimelineFields = document.getElementById("manual-fields");
const downloadBase = "{{ url_for('download') }}";
const isRunning = {{ 'true' if running else 'false' }};
+ const defaultTurns = {{ DEFAULT_WINDING_TURNS | tojson }};
+ const defaultFill = {{ DEFAULT_FILL_FRACTION | tojson }};
+ let currentBlobUrl = null;
+ const lastImageUrl = {{ (last_visualization_url or '') | tojson | safe }};
+ const initialFrames = {{ last_visualization_frames | tojson | safe }} || [];
+ let frameGallery = Array.isArray(initialFrames)
+ ? initialFrames.map(function (frame) {
+ return {
+ image: frame.image,
+ caption: frame.caption || '',
+ frame_index: typeof frame.frame_index === 'number' ? frame.frame_index : null,
+ field_id: frame.field_id || null,
+ sequence_index: typeof frame.sequence_index === 'number' ? frame.sequence_index : null,
+ };
+ })
+ : [];
+ frameGallery.sort(function (a, b) {
+ const left = typeof a.sequence_index === 'number' ? a.sequence_index : 0;
+ const right = typeof b.sequence_index === 'number' ? b.sequence_index : 0;
+ return left - right;
+ });
+ let activeFrameIndex = frameGallery.length ? frameGallery.length - 1 : -1;
+ let playbackTimer = null;
+ let isPlaying = false;
+
+ function renumberMaterialRows() {
+ if (!materialsRows || !materialsCountInput) {
+ return;
+ }
+ const rows = materialsRows.querySelectorAll('.material-row');
+ rows.forEach(function (row, idx) {
+ row.dataset.index = String(idx);
+ const nameInput = row.querySelector("input[name$='-name']");
+ const muInput = row.querySelector("input[name$='-mu']");
+ const sigmaInput = row.querySelector("input[name$='-sigma']");
+ const muLabel = row.querySelector("label[for^='material-'][for$='-mu']");
+ const sigmaLabel = row.querySelector("label[for^='material-'][for$='-sigma']");
+ if (nameInput) {
+ nameInput.name = 'material-' + idx + '-name';
+ }
+ if (muInput) {
+ muInput.name = 'material-' + idx + '-mu';
+ muInput.id = 'material-' + idx + '-mu';
+ }
+ if (muLabel) {
+ muLabel.setAttribute('for', 'material-' + idx + '-mu');
+ }
+ if (sigmaInput) {
+ sigmaInput.name = 'material-' + idx + '-sigma';
+ sigmaInput.id = 'material-' + idx + '-sigma';
+ }
+ if (sigmaLabel) {
+ sigmaLabel.setAttribute('for', 'material-' + idx + '-sigma');
+ }
+ });
+ materialsCountInput.value = String(rows.length);
+ }
+
+ function addMaterialRow(initial) {
+ if (!materialsRows || !materialsCountInput) {
+ return;
+ }
+ const index = parseInt(materialsCountInput.value || '0', 10);
+ const nameValue = initial && initial.name ? initial.name : 'Material ' + (index + 1);
+ const muValue = initial && typeof initial.mu !== 'undefined' ? initial.mu : 1.0;
+ const sigmaValue = initial && typeof initial.sigma !== 'undefined' ? initial.sigma : '';
+ const wrapper = document.createElement('div');
+ wrapper.className = 'col-lg-6 material-row';
+ wrapper.dataset.index = String(index);
+ wrapper.innerHTML = `
+
+
+
+
+
+
+
+
`;
+ const sigmaInput = wrapper.querySelector("input[name='material-" + index + "-sigma']");
+ if (sigmaInput && sigmaValue !== '') {
+ sigmaInput.value = sigmaValue;
+ }
+ materialsRows.appendChild(wrapper);
+ materialsCountInput.value = String(index + 1);
+ }
+
+ function renumberWindingRows() {
+ if (!windingsRows || !windingsCountInput) {
+ return;
+ }
+ const rows = windingsRows.querySelectorAll('.winding-row');
+ rows.forEach(function (row, idx) {
+ row.dataset.index = String(idx);
+ const nameInput = row.querySelector("input[name$='-name']");
+ const phaseInput = row.querySelector("input[name$='-phase']");
+ const turnsInput = row.querySelector("input[name$='-turns']");
+ const fillInput = row.querySelector("input[name$='-fill']");
+ const orientationSelect = row.querySelector("select[name$='-orientation']");
+ const layersSelect = row.querySelector("select[name$='-layers']");
+ if (nameInput) {
+ nameInput.name = 'winding-' + idx + '-name';
+ nameInput.id = 'winding-' + idx + '-name';
+ }
+ if (phaseInput) {
+ phaseInput.name = 'winding-' + idx + '-phase';
+ phaseInput.id = 'winding-' + idx + '-phase';
+ }
+ if (turnsInput) {
+ turnsInput.name = 'winding-' + idx + '-turns';
+ turnsInput.id = 'winding-' + idx + '-turns';
+ }
+ if (fillInput) {
+ fillInput.name = 'winding-' + idx + '-fill';
+ fillInput.id = 'winding-' + idx + '-fill';
+ }
+ if (orientationSelect) {
+ orientationSelect.name = 'winding-' + idx + '-orientation';
+ orientationSelect.id = 'winding-' + idx + '-orientation';
+ }
+ if (layersSelect) {
+ layersSelect.name = 'winding-' + idx + '-layers';
+ layersSelect.id = 'winding-' + idx + '-layers';
+ }
+ });
+ windingsCountInput.value = String(rows.length);
+ }
+
+ function addWindingRow(initial) {
+ if (!windingsRows || !windingsCountInput) {
+ return;
+ }
+ const index = parseInt(windingsCountInput.value || '0', 10);
+ const nameValue = initial && initial.name ? initial.name : 'Winding ' + (index + 1);
+ const phaseValue = initial && initial.phase ? initial.phase : '';
+ const turnsValue = initial && typeof initial.turns !== 'undefined' ? initial.turns : defaultTurns;
+ const fillValue = initial && typeof initial.fill_fraction !== 'undefined' ? initial.fill_fraction : defaultFill;
+ const orientationValue = initial && typeof initial.orientation !== 'undefined' ? Number(initial.orientation) : 1;
+ const wrapper = document.createElement('div');
+ wrapper.className = 'border rounded p-3 mb-3 winding-row';
+ wrapper.dataset.index = String(index);
+ const layerOptions = layerOptionsTemplate ? layerOptionsTemplate.innerHTML : '';
+ wrapper.innerHTML = `
+
+ ${nameValue}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Select all conductor layers that belong to this winding (hold Ctrl/Cmd for multi-select).
+
+
`;
+ if (initial && Array.isArray(initial.layers)) {
+ const selectEl = wrapper.querySelector('select');
+ if (selectEl) {
+ const values = new Set(initial.layers);
+ selectEl.querySelectorAll('option').forEach(function (option) {
+ option.selected = values.has(option.value);
+ });
+ }
+ }
+ windingsRows.appendChild(wrapper);
+ windingsCountInput.value = String(index + 1);
+ }
+
+ if (materialsRows) {
+ materialsRows.addEventListener('click', function (event) {
+ if (event.target && event.target.matches('[data-remove-material]')) {
+ const row = event.target.closest('.material-row');
+ if (row) {
+ row.remove();
+ renumberMaterialRows();
+ }
+ }
+ });
+ }
+
+ if (addMaterialBtn) {
+ addMaterialBtn.addEventListener('click', function () {
+ addMaterialRow();
+ });
+ }
+
+ if (windingsRows) {
+ windingsRows.addEventListener('click', function (event) {
+ if (event.target && event.target.matches('[data-remove-winding]')) {
+ const row = event.target.closest('.winding-row');
+ if (row) {
+ row.remove();
+ renumberWindingRows();
+ }
+ }
+ });
+ }
+
+ if (addWindingBtn) {
+ addWindingBtn.addEventListener('click', function () {
+ addWindingRow();
+ });
+ }
+
+ function syncTimelineMode() {
+ if (!timelineModeSelect || !manualTimelineFields) {
+ return;
+ }
+ if (timelineModeSelect.value === 'manual') {
+ manualTimelineFields.classList.remove('d-none');
+ } else {
+ manualTimelineFields.classList.add('d-none');
+ }
+ }
+
+ if (timelineModeSelect) {
+ timelineModeSelect.addEventListener('change', syncTimelineMode);
+ syncTimelineMode();
+ }
+
+ renumberMaterialRows();
+ renumberWindingRows();
+
+ const navLinks = document.querySelectorAll('.app-sidebar .nav-link');
+ function setActiveNav(target) {
+ if (!target) {
+ target = '#project-overview';
+ }
+ navLinks.forEach(function (link) {
+ if (link.getAttribute('href') === target) {
+ link.classList.add('active');
+ } else {
+ link.classList.remove('active');
+ }
+ });
+ }
+
+ function stopPlayback() {
+ if (playbackTimer) {
+ clearInterval(playbackTimer);
+ playbackTimer = null;
+ }
+ isPlaying = false;
+ if (vizPlayBtn) {
+ vizPlayBtn.textContent = 'Play';
+ }
+ }
+
+ function updateControlsState() {
+ if (!vizControls) {
+ return;
+ }
+ if (frameGallery.length === 0) {
+ vizControls.classList.add('d-none');
+ if (vizPrevBtn) vizPrevBtn.disabled = true;
+ if (vizNextBtn) vizNextBtn.disabled = true;
+ if (vizPlayBtn) vizPlayBtn.disabled = true;
+ if (vizSlider) {
+ vizSlider.disabled = true;
+ vizSlider.value = '0';
+ vizSlider.max = '0';
+ }
+ if (vizFrameLabel) {
+ vizFrameLabel.textContent = '';
+ }
+ return;
+ }
+
+ vizControls.classList.remove('d-none');
+ const disableNav = frameGallery.length <= 1;
+ if (vizPrevBtn) vizPrevBtn.disabled = disableNav;
+ if (vizNextBtn) vizNextBtn.disabled = disableNav;
+ if (vizPlayBtn) vizPlayBtn.disabled = disableNav;
+ if (vizSlider) {
+ vizSlider.disabled = disableNav;
+ vizSlider.max = String(frameGallery.length - 1);
+ }
+ }
+
+ function clearLiveFrames() {
+ stopPlayback();
+ frameGallery = [];
+ activeFrameIndex = -1;
+ updateControlsState();
+ }
+
+ function buildFrameLabel(frame) {
+ const labelParts = [];
+ if (typeof frame.frame_index === 'number') {
+ labelParts.push('Frame ' + frame.frame_index);
+ }
+ if (frame.field_id) {
+ labelParts.push(frame.field_id);
+ }
+ return labelParts.join(' • ');
+ }
+
+ function showFrame(index) {
+ if (!Array.isArray(frameGallery) || frameGallery.length === 0) {
+ return;
+ }
+ if (index < 0 || index >= frameGallery.length) {
+ return;
+ }
+ const frame = frameGallery[index];
+ activeFrameIndex = index;
+ stopPlayback();
+ if (currentBlobUrl) {
+ URL.revokeObjectURL(currentBlobUrl);
+ currentBlobUrl = null;
+ }
+ if (visualizationPanel) {
+ visualizationPanel.style.display = 'block';
+ }
+ const baseUrl = frame.image;
+ if (baseUrl) {
+ const url = baseUrl + (baseUrl.includes('?') ? '&' : '?') + 't=' + Date.now();
+ resultImage.src = url;
+ }
+ resultImage.classList.remove('d-none');
+ if (visualizationMessage) {
+ visualizationMessage.classList.add('d-none');
+ }
+ if (visualizationCaption) {
+ if (frame.caption) {
+ visualizationCaption.textContent = frame.caption;
+ visualizationCaption.classList.remove('d-none');
+ } else {
+ visualizationCaption.classList.add('d-none');
+ }
+ }
+ if (vizSlider) {
+ vizSlider.value = String(index);
+ }
+ if (vizFrameLabel) {
+ const label = buildFrameLabel(frame);
+ vizFrameLabel.textContent = label;
+ vizFrameLabel.classList.toggle('d-none', !label);
+ }
+ }
+
+ function registerFrame(details) {
+ if (!details || !details.image) {
+ return;
+ }
+ const frame = {
+ image: details.image,
+ caption: details.caption || '',
+ frame_index: typeof details.frame_index === 'number' ? details.frame_index : null,
+ field_id: details.field_id || null,
+ sequence_index: typeof details.sequence_index === 'number' ? details.sequence_index : null,
+ };
+ let targetIndex = -1;
+ if (typeof frame.sequence_index === 'number') {
+ targetIndex = frameGallery.findIndex(function (item) {
+ return typeof item.sequence_index === 'number' && item.sequence_index === frame.sequence_index;
+ });
+ }
+ if (targetIndex === -1) {
+ targetIndex = frameGallery.findIndex(function (item) {
+ return item.image === frame.image;
+ });
+ }
+ if (targetIndex >= 0) {
+ frameGallery[targetIndex] = frame;
+ } else {
+ frameGallery.push(frame);
+ frameGallery.sort(function (a, b) {
+ const left = typeof a.sequence_index === 'number' ? a.sequence_index : 0;
+ const right = typeof b.sequence_index === 'number' ? b.sequence_index : 0;
+ return left - right;
+ });
+ targetIndex = frameGallery.findIndex(function (item) {
+ return item.image === frame.image;
+ });
+ }
+ updateControlsState();
+ if (targetIndex >= 0) {
+ showFrame(targetIndex);
+ }
+ }
+
+ function advanceFrame(step) {
+ if (frameGallery.length === 0) {
+ return;
+ }
+ const nextIndex = (activeFrameIndex + step + frameGallery.length) % frameGallery.length;
+ showFrame(nextIndex);
+ }
+
+ function startPlayback() {
+ if (isPlaying || frameGallery.length <= 1) {
+ return;
+ }
+ isPlaying = true;
+ if (vizPlayBtn) {
+ vizPlayBtn.textContent = 'Pause';
+ }
+ playbackTimer = setInterval(function () {
+ advanceFrame(1);
+ }, 1500);
+ }
+
+ navLinks.forEach(function (link) {
+ link.addEventListener('click', function () {
+ setActiveNav(link.getAttribute('href'));
+ });
+ });
+
+ window.addEventListener('hashchange', function () {
+ setActiveNav(location.hash);
+ });
+
+ setActiveNav(location.hash);
+ updateControlsState();
+ if (frameGallery.length > 0) {
+ let initialIndex = frameGallery.findIndex(function (frame) {
+ return frame.image === lastImageUrl;
+ });
+ if (initialIndex < 0) {
+ initialIndex = frameGallery.length - 1;
+ }
+ showFrame(initialIndex);
+ }
if (isRunning) {
progressContainer.style.display = 'block';
@@ -84,7 +1156,11 @@
Downloads
}
if (runForm) {
- runForm.addEventListener("submit", function () {
+ runForm.addEventListener("submit", function (event) {
+ const submitter = event.submitter;
+ if (submitter && submitter.value === 'preview') {
+ return;
+ }
runBtn.disabled = true;
stopBtn.disabled = false;
progressContainer.style.display = 'block';
@@ -95,6 +1171,57 @@
Downloads
progressBar.classList.remove('bg-danger', 'bg-success');
progressBar.style.width = '0%';
progressBar.textContent = '0%';
+ if (visualizationPanel) {
+ visualizationPanel.style.display = 'none';
+ }
+ if (visualizationMessage) {
+ visualizationMessage.classList.add('d-none');
+ visualizationMessage.textContent = '';
+ }
+ if (visualizationCaption) {
+ visualizationCaption.classList.add('d-none');
+ visualizationCaption.textContent = '';
+ }
+ if (visualizationError) {
+ visualizationError.classList.add('d-none');
+ visualizationError.textContent = '';
+ }
+ if (updateVizBtn) {
+ updateVizBtn.disabled = true;
+ }
+ clearLiveFrames();
+ });
+ }
+
+ if (vizPrevBtn) {
+ vizPrevBtn.addEventListener('click', function () {
+ advanceFrame(-1);
+ });
+ }
+
+ if (vizNextBtn) {
+ vizNextBtn.addEventListener('click', function () {
+ advanceFrame(1);
+ });
+ }
+
+ if (vizPlayBtn) {
+ vizPlayBtn.addEventListener('click', function () {
+ if (isPlaying) {
+ stopPlayback();
+ } else {
+ startPlayback();
+ }
+ });
+ }
+
+ if (vizSlider) {
+ vizSlider.addEventListener('input', function () {
+ const index = parseInt(vizSlider.value, 10);
+ if (!Number.isNaN(index)) {
+ stopPlayback();
+ showFrame(index);
+ }
});
}
@@ -168,8 +1295,120 @@
Downloads
resultsList.appendChild(entry);
});
}
+ if (payload.visualization) {
+ handleVisualization(payload.visualization);
+ }
+ } else if (payload.visualization) {
+ handleVisualization(payload.visualization);
}
};
+
+ function handleVisualization(details) {
+ if (!details) {
+ return;
+ }
+ if (visualizationPanel) {
+ visualizationPanel.style.display = 'block';
+ }
+ if (visualizationError) {
+ visualizationError.classList.add('d-none');
+ visualizationError.textContent = '';
+ }
+ if (details.image) {
+ const url = details.image + (details.image.includes('?') ? '&' : '?') + 't=' + Date.now();
+ resultImage.src = url;
+ resultImage.classList.remove('d-none');
+ if (visualizationMessage) {
+ visualizationMessage.classList.add('d-none');
+ }
+ if (visualizationCaption) {
+ if (details.caption) {
+ visualizationCaption.textContent = details.caption;
+ visualizationCaption.classList.remove('d-none');
+ } else {
+ visualizationCaption.classList.add('d-none');
+ }
+ }
+ if (updateVizBtn) {
+ updateVizBtn.disabled = false;
+ }
+ registerFrame(details);
+ } else if (details.message) {
+ if (visualizationMessage) {
+ visualizationMessage.textContent = details.message;
+ visualizationMessage.classList.remove('d-none');
+ }
+ resultImage.classList.add('d-none');
+ if (visualizationCaption) {
+ visualizationCaption.classList.add('d-none');
+ }
+ if (updateVizBtn) {
+ updateVizBtn.disabled = true;
+ }
+ clearLiveFrames();
+ }
+ }
+
+ if (updateVizBtn && vizForm) {
+ updateVizBtn.addEventListener('click', function () {
+ if (!vizForm.dataset.endpoint) {
+ return;
+ }
+ const formData = new FormData(vizForm);
+ if (!formData.has('boundaries')) {
+ formData.set('boundaries', document.getElementById('draw-boundaries').checked ? '1' : '0');
+ }
+ if (!formData.has('stream')) {
+ formData.set('stream', document.getElementById('streamlines').checked ? '1' : '0');
+ }
+ const params = new URLSearchParams(formData);
+ params.set('t', Date.now().toString());
+ const endpoint = vizForm.dataset.endpoint + '?' + params.toString();
+ updateVizBtn.disabled = true;
+ if (visualizationError) {
+ visualizationError.classList.add('d-none');
+ visualizationError.textContent = '';
+ }
+ fetch(endpoint)
+ .then(function (response) {
+ if (!response.ok) {
+ throw new Error('Request failed');
+ }
+ return response.blob();
+ })
+ .then(function (blob) {
+ if (currentBlobUrl) {
+ URL.revokeObjectURL(currentBlobUrl);
+ }
+ currentBlobUrl = URL.createObjectURL(blob);
+ stopPlayback();
+ if (visualizationPanel) {
+ visualizationPanel.style.display = 'block';
+ }
+ resultImage.src = currentBlobUrl;
+ resultImage.classList.remove('d-none');
+ if (visualizationMessage) {
+ visualizationMessage.classList.add('d-none');
+ }
+ if (visualizationCaption) {
+ visualizationCaption.classList.add('d-none');
+ }
+ if (vizFrameLabel) {
+ vizFrameLabel.textContent = '';
+ vizFrameLabel.classList.add('d-none');
+ }
+ })
+ .catch(function () {
+ if (visualizationError) {
+ visualizationError.textContent = 'Unable to refresh the visualisation.';
+ visualizationError.classList.remove('d-none');
+ }
+ })
+ .finally(function () {
+ updateVizBtn.disabled = false;
+ });
+ });
+ }
});
{% endblock %}
diff --git a/python/visualize_scenario_field.py b/python/visualize_scenario_field.py
index 750efb3..a35f957 100755
--- a/python/visualize_scenario_field.py
+++ b/python/visualize_scenario_field.py
@@ -9,7 +9,7 @@
import pathlib
import xml.etree.ElementTree as ET
from dataclasses import dataclass
-from typing import Dict, List, Optional, Tuple
+from typing import Dict, IO, List, Optional, Tuple
import matplotlib.pyplot as plt
from matplotlib.colors import LogNorm
@@ -461,7 +461,7 @@ def plot_field(
bmag: np.ndarray,
wires: List[Wire],
quiver_skip: int,
- save: pathlib.Path | None,
+ save: pathlib.Path | IO[bytes] | None,
title: str,
color_scale: str,
log_floor: float,
@@ -473,6 +473,8 @@ def plot_field(
streamlines: bool,
analytic: Optional[Dict[str, np.ndarray]],
analytic_levels: int,
+ *,
+ show: bool = True,
) -> None:
fig, ax = plt.subplots(figsize=(7, 6))
display_mag = bmag
@@ -575,9 +577,13 @@ def plot_field(
if save is not None:
fig.savefig(save, dpi=200, bbox_inches="tight")
- print(f"Saved figure to {save}")
+ if isinstance(save, pathlib.Path):
+ print(f"Saved figure to {save}")
- plt.show()
+ if show:
+ plt.show()
+ else:
+ plt.close(fig)
def main() -> None:
diff --git a/scripts/maintain_gui_env.sh b/scripts/maintain_gui_env.sh
new file mode 100755
index 0000000..272f7c5
--- /dev/null
+++ b/scripts/maintain_gui_env.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Light-weight housekeeping for the Flask GUI runtime assets.
+#
+# The script prunes stale uploads/results and surfaces dependency
+# inconsistencies so environments stay clean between simulation runs.
+#
+# Usage:
+# ./scripts/maintain_gui_env.sh [days-to-keep]
+# KEEP_DAYS=3 ./scripts/maintain_gui_env.sh
+
+ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+KEEP_DAYS=${1:-${KEEP_DAYS:-7}}
+UPLOAD_DIR="${ROOT_DIR}/python/gui/uploads"
+RESULTS_DIR="${ROOT_DIR}/python/gui/results"
+PYTHON_BIN=${PYTHON:-python3}
+
+prune_dir() {
+ local path="$1"
+ if [[ -d "$path" ]]; then
+ find "$path" -type f -mtime "+${KEEP_DAYS}" -print -delete
+ fi
+}
+
+prune_dir "$UPLOAD_DIR"
+prune_dir "$RESULTS_DIR"
+
+"${PYTHON_BIN}" -m pip check >/dev/null
+
+echo "[maintain_gui_env] Removed artefacts older than ${KEEP_DAYS} day(s)."
+
+outdated=$("${PYTHON_BIN}" -m pip list --outdated)
+if [[ $(echo "$outdated" | awk 'END {print NR}') -gt 2 ]]; then
+ echo "[maintain_gui_env] The following packages can be updated:" >&2
+ echo "$outdated" >&2
+else
+ echo "[maintain_gui_env] All installed packages are up to date." >&2
+fi
diff --git a/scripts/setup_gui_e2e_env.sh b/scripts/setup_gui_e2e_env.sh
new file mode 100755
index 0000000..99ed068
--- /dev/null
+++ b/scripts/setup_gui_e2e_env.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Bootstrap dependencies required to run the Flask GUI end-to-end Playwright suite.
+# Usage:
+# ./scripts/setup_gui_e2e_env.sh [python-executable]
+# PYTHON=python3.11 ./scripts/setup_gui_e2e_env.sh
+#
+# The script installs Flask GUI runtime deps plus pytest-playwright and the
+# Chromium browser binary used by the E2E test.
+
+ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
+PYTHON_BIN=${1:-${PYTHON:-python3}}
+
+"${PYTHON_BIN}" -m pip install --upgrade pip
+"${PYTHON_BIN}" -m pip install --upgrade \
+ flask \
+ matplotlib \
+ numpy \
+ ezdxf \
+ pytest \
+ pytest-playwright \
+ playwright
+
+"${PYTHON_BIN}" -m playwright install chromium
+
+mkdir -p "${ROOT_DIR}/python/gui/uploads" "${ROOT_DIR}/python/gui/results"
+
+cat <
None:
+ path.write_text(
+ "\n".join(
+ [
+ "0",
+ "SECTION",
+ "2",
+ "ENTITIES",
+ "0",
+ "LINE",
+ "8",
+ layer,
+ "10",
+ "0",
+ "20",
+ "0",
+ "11",
+ "1",
+ "21",
+ "1",
+ "0",
+ "ENDSEC",
+ "0",
+ "EOF",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ domain_dxf = assets_dir / "domain.dxf"
+ rotor_bars_dxf = assets_dir / "rotor_bars.dxf"
+ _write_minimal_dxf(domain_dxf, "DOMAIN")
+ _write_minimal_dxf(rotor_bars_dxf, "ROTOR")
+
+ return {
+ "scenario": scenario_copy,
+ "domain_dxf": domain_dxf,
+ "rotor_bars_dxf": rotor_bars_dxf,
+ }
+
+
+@pytest.fixture()
+def live_server(tmp_path_factory, monkeypatch):
+ uploads = tmp_path_factory.mktemp("uploads")
+ results = tmp_path_factory.mktemp("results")
+
+ app_flask.app.config.update(
+ TESTING=True,
+ SERVER_NAME=None,
+ UPLOAD_FOLDER=str(uploads),
+ RESULTS_FOLDER=str(results),
+ SECRET_KEY="e2e-secret",
+ )
+ app_flask.manager.reset()
+ app_flask.PROJECTS.clear()
+
+ bootstrap_dir = tmp_path_factory.mktemp("bootstrap")
+ bootstrap_dxf = bootstrap_dir / "bootstrap.dxf"
+ bootstrap_dxf.write_text(
+ "\n".join(
+ [
+ "0",
+ "SECTION",
+ "2",
+ "ENTITIES",
+ "0",
+ "LINE",
+ "8",
+ "BOOT",
+ "10",
+ "0",
+ "20",
+ "0",
+ "11",
+ "1",
+ "21",
+ "1",
+ "0",
+ "ENDSEC",
+ "0",
+ "EOF",
+ ]
+ ),
+ encoding="utf-8",
+ )
+
+ test_client = app_flask.app.test_client()
+ upload_response = test_client.post(
+ "/dxf/upload",
+ data={"dxf_files": (open(bootstrap_dxf, "rb"), bootstrap_dxf.name)},
+ content_type="multipart/form-data",
+ )
+ session_cookie = None
+ for cookie_header in upload_response.headers.getlist("Set-Cookie"):
+ if cookie_header.startswith("session="):
+ session_cookie = cookie_header.split(";", 1)[0].split("=", 1)[1]
+ break
+
+ if not session_cookie:
+ raise RuntimeError("Bootstrap DXF upload did not return a session cookie")
+
+ def _fake_run(self, command): # type: ignore[no-untyped-def]
+ scenario_path = self._metadata.get("scenario_path")
+ log_path = self._metadata.get("log_path")
+ process_cwd = Path(self._metadata.get("process_cwd", Path.cwd()))
+
+ field_map_path = process_cwd / "outputs" / "induction_motor_field.csv"
+ field_map_path.parent.mkdir(parents=True, exist_ok=True)
+ field_map_path.write_text(
+ "x,y,Bx,By,Bmag\n" "0.0,0.0,0.1,0.0,0.1\n" "0.1,0.0,0.1,0.0,0.1\n",
+ encoding="utf-8",
+ )
+
+ if log_path:
+ Path(log_path).write_text(
+ "Frame 0: wrote field_map 'induction_motor_field' to \"outputs/induction_motor_field.csv\"\n"
+ "Simulation complete.\n",
+ encoding="utf-8",
+ )
+
+ self.queue.put({
+ "started": True,
+ "message": "Simulation launched.",
+ "progress": 5,
+ })
+ self.queue.put({
+ "message": "Frame 0: wrote field_map 'induction_motor_field' to \"outputs/induction_motor_field.csv\"",
+ "progress": 90,
+ })
+ self._handle_field_map_event(
+ frame=0, field_id="induction_motor_field", path_str="outputs/induction_motor_field.csv"
+ )
+
+ with self._lock:
+ self._running = False
+ self._process = None
+
+ if scenario_path:
+ self._emit_completion(success=True, message="Simulation complete.", scenario_path=scenario_path)
+
+ monkeypatch.setattr(app_flask.SimulationManager, "_run_process", _fake_run, raising=False)
+
+ server = make_server("127.0.0.1", 0, app_flask.app)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+
+ yield f"http://127.0.0.1:{server.server_port}", session_cookie
+
+ server.shutdown()
+ thread.join()
+ app_flask.manager.reset()
+ app_flask.PROJECTS.clear()
+
+
+@pytest.fixture()
+def page(live_server, playwright):
+ server_url, session_cookie = live_server
+ try:
+ browser = playwright.chromium.launch()
+ except Exception as exc: # pragma: no cover - environment guard
+ pytest.skip(f"Playwright Chromium launch failed: {exc}")
+
+ context = browser.new_context()
+ context.add_cookies(
+ [
+ {
+ "name": "session",
+ "value": session_cookie,
+ "url": server_url,
+ }
+ ]
+ )
+ page = context.new_page()
+ page.goto(server_url)
+ yield page
+ context.close()
+ browser.close()
+
+
+@pytest.fixture(scope="session")
+def playwright():
+ with playwright_sync.sync_playwright() as p:
+ yield p
+
+
+def test_induction_workflow_end_to_end(page, sample_assets):
+ expect(page.get_by_test_id("dxf-upload-input")).to_be_attached()
+ expect(page.get_by_test_id("run-simulation")).to_be_attached()
diff --git a/tests/test_gui_flask.py b/tests/test_gui_flask.py
index f4f4605..ad88e36 100644
--- a/tests/test_gui_flask.py
+++ b/tests/test_gui_flask.py
@@ -1,12 +1,17 @@
import io
import json
import queue
-import time
import sys
+import time
+import zipfile
from pathlib import Path
from typing import List
+import ezdxf
import pytest
+from werkzeug.datastructures import MultiDict
+
+pytest.importorskip("flask")
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -22,8 +27,10 @@ def _reset_manager(tmp_path, monkeypatch):
app_flask.app.config["UPLOAD_FOLDER"] = str(uploads)
app_flask.app.config["RESULTS_FOLDER"] = str(results)
app_flask.manager.reset()
+ app_flask.PROJECTS.clear()
yield
app_flask.manager.reset()
+ app_flask.PROJECTS.clear()
@pytest.fixture
@@ -32,6 +39,85 @@ def client():
yield test_client
+@pytest.fixture
+def visualization_ready(tmp_path):
+ scenario = tmp_path / "viz_scenario.json"
+ scenario.write_text(
+ json.dumps(
+ {
+ "version": "0.2",
+ "name": "visualisation fixture",
+ "domain": {"Lx": 0.1, "Ly": 0.1},
+ "sources": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ field_map = tmp_path / "outputs" / "fixture_field.csv"
+ field_map.parent.mkdir(parents=True, exist_ok=True)
+ field_map.write_text(
+ "x,y,Bx,By,Bmag\n"
+ "0.0,0.0,0.1,0.0,0.1\n"
+ "0.0,0.1,0.1,0.0,0.1\n"
+ "0.1,0.0,0.1,0.0,0.1\n"
+ "0.1,0.1,0.1,0.0,0.1\n",
+ encoding="utf-8",
+ )
+
+ app_flask.manager._last_result = { # type: ignore[attr-defined]
+ "scenario_path": str(scenario),
+ "field_map": str(field_map),
+ "settings": {
+ "vector_mode": app_flask.DEFAULT_VECTOR_MODE,
+ "color_scale": app_flask.DEFAULT_COLOR_SCALE,
+ "quiver_skip": app_flask.DEFAULT_QUIVER_SKIP,
+ "draw_boundaries": True,
+ "streamlines": False,
+ },
+ }
+
+ return {"scenario": scenario, "field_map": field_map}
+
+
+def _create_project(client, *, name: str = "fixture") -> str:
+ scenario_payload = json.dumps(
+ {
+ "version": "0.2",
+ "name": name,
+ "domain": {"Lx": 0.2, "Ly": 0.1, "nx": 41, "ny": 41},
+ "materials": [
+ {"name": "air", "mu_r": 1.0},
+ {"name": "steel", "mu_r": 500.0},
+ ],
+ "regions": [
+ {"type": "uniform", "material": "air"},
+ ],
+ "sources": [],
+ }
+ ).encode("utf-8")
+
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), f"{name}.json"),
+ "action": "preview",
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "10",
+ },
+ content_type="multipart/form-data",
+ )
+
+ assert response.status_code == 200
+
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+
+ assert project_id in app_flask.PROJECTS
+ return project_id # type: ignore[return-value]
+
+
def test_upload_starts_simulation(monkeypatch, client):
popen_calls: List[List[str]] = []
@@ -90,6 +176,546 @@ def fake_popen(cmd, **kwargs): # noqa: ANN001 - signature mirrors subprocess
assert not app_flask.manager.is_running
+def test_preview_generates_geometry_image(client):
+ payload = json.dumps(
+ {
+ "version": "0.2",
+ "domain": {"Lx": 0.2, "Ly": 0.1},
+ "sources": [],
+ }
+ ).encode("utf-8")
+
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(payload), "preview.json"),
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "100",
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ )
+
+ assert response.status_code == 200
+ body = response.data.decode("utf-8")
+ assert "geometry-preview-image" in body
+ results_dir = Path(app_flask.app.config["RESULTS_FOLDER"])
+ generated = list(results_dir.glob("geometry_preview_*.png"))
+ assert generated, "Expected a preview image to be created"
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+ assert project_id in app_flask.PROJECTS
+
+
+def test_preview_invalid_json_reports_error(client):
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(b"not-json"), "invalid.json"),
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ follow_redirects=True,
+ )
+
+ assert response.status_code == 200
+ assert "Uploaded JSON is invalid" in response.get_data(as_text=True)
+
+
+def test_run_after_preview_without_new_upload(monkeypatch, client):
+ popen_calls: List[List[str]] = []
+
+ class DummyProcess:
+ def __init__(self) -> None:
+ self.stdout = io.StringIO("10%\nFinished\n")
+ self.returncode = 0
+ self.terminated = False
+
+ def wait(self) -> int:
+ return self.returncode
+
+ def terminate(self) -> None:
+ self.terminated = True
+
+ def fake_popen(cmd, **kwargs): # noqa: ANN001 - signature mirrors subprocess
+ popen_calls.append(cmd)
+ return DummyProcess()
+
+ monkeypatch.setattr(app_flask.subprocess, "Popen", fake_popen)
+
+ scenario_payload = json.dumps({"version": "0.2", "domain": {"Lx": 0.1, "Ly": 0.1}, "sources": []}).encode(
+ "utf-8"
+ )
+
+ preview_response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), "reuse.json"),
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "50",
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ )
+
+ assert preview_response.status_code == 200
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+
+ assert project_id in app_flask.PROJECTS
+ stored_path = app_flask.PROJECTS[project_id]["scenario_path"]
+
+ response = client.post(
+ "/upload",
+ data={
+ "project_id": project_id,
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "5",
+ "action": "run",
+ },
+ content_type="multipart/form-data",
+ )
+
+ assert response.status_code == 302
+
+ deadline = time.time() + 1
+ while time.time() < deadline and not popen_calls:
+ time.sleep(0.01)
+
+ assert popen_calls, "subprocess.Popen should run without re-uploading"
+ assert str(stored_path) in popen_calls[0]
+
+
+def test_visualization_route_after_run(monkeypatch, tmp_path, client):
+ stdout_queue: "queue.Queue[str | None]" = queue.Queue()
+
+ class DummyStdout:
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ item = stdout_queue.get()
+ if item is None:
+ raise StopIteration
+ return item
+
+ class DummyProcess:
+ def __init__(self) -> None:
+ self.stdout = DummyStdout()
+ self.returncode = 0
+
+ def wait(self) -> int:
+ return self.returncode
+
+ def terminate(self) -> None:
+ self.returncode = 143
+
+ def fake_popen(cmd, **kwargs): # noqa: ANN001 - signature mirrors subprocess
+ return DummyProcess()
+
+ monkeypatch.setattr(app_flask.subprocess, "Popen", fake_popen)
+ monkeypatch.chdir(tmp_path)
+
+ scenario_payload = json.dumps(
+ {
+ "version": "0.2",
+ "domain": {"Lx": 0.1, "Ly": 0.1},
+ "sources": [
+ {"type": "wire", "x": 0.0, "y": 0.0, "radius": 0.002, "I": 5.0}
+ ],
+ "outputs": [
+ {
+ "type": "field_map",
+ "id": "domain_field",
+ "path": "outputs/test_field.csv",
+ }
+ ],
+ }
+ ).encode("utf-8")
+
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), "scenario.json"),
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "100",
+ "outputs": "domain_field",
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+
+ deadline = time.time() + 2
+ scenario_path: Path | None = None
+ while time.time() < deadline:
+ scenario = app_flask.manager._metadata.get("scenario_path") # type: ignore[attr-defined]
+ if isinstance(scenario, Path):
+ scenario_path = scenario
+ break
+ time.sleep(0.01)
+
+ assert scenario_path is not None
+
+ field_path = Path.cwd() / "outputs" / "test_field.csv"
+ field_path.parent.mkdir(parents=True, exist_ok=True)
+ field_path.write_text(
+ "x,y,Bx,By,Bmag\n"
+ "0.0,0.0,0.1,0.0,0.1\n"
+ "0.05,0.0,0.1,0.0,0.1\n"
+ "0.0,0.05,0.1,0.0,0.1\n"
+ "0.05,0.05,0.1,0.0,0.1\n",
+ encoding="utf-8",
+ )
+
+ stdout_queue.put("10%\n")
+ stdout_queue.put("Frame 0: wrote field_map 'domain_field' to \"outputs/test_field.csv\"\n")
+ stdout_queue.put("Finished\n")
+ stdout_queue.put(None)
+
+ events = []
+ deadline = time.time() + 3
+ while time.time() < deadline:
+ try:
+ item = app_flask.manager.queue.get(timeout=0.1)
+ events.append(item)
+ if item.get("complete"):
+ break
+ except queue.Empty:
+ pass
+
+ assert any("visualization" in evt for evt in events if isinstance(evt, dict))
+ last_result = app_flask.manager.get_last_result()
+ assert Path(last_result.get("field_map", "")).exists()
+
+ viz_response = client.get(
+ "/visualization.png",
+ query_string={"vector": "off", "boundaries": "0", "skip": "2"},
+ )
+ assert viz_response.status_code == 200
+ assert viz_response.mimetype == "image/png"
+ payload = b"".join(viz_response.response)
+ assert payload, "Expected PNG bytes from visualisation route"
+
+
+def test_visualization_detects_outputs_from_process_cwd(monkeypatch, client, tmp_path):
+ stdout_queue: "queue.Queue[str | None]" = queue.Queue()
+
+ class DummyStdout:
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ item = stdout_queue.get()
+ if item is None:
+ raise StopIteration
+ return item
+
+ class DummyProcess:
+ def __init__(self) -> None:
+ self.stdout = DummyStdout()
+ self.returncode = 0
+
+ def wait(self) -> int:
+ return self.returncode
+
+ def terminate(self) -> None:
+ self.returncode = 143
+
+ def fake_popen(cmd, **kwargs): # noqa: ANN001 - signature mirrors subprocess
+ return DummyProcess()
+
+ monkeypatch.setattr(app_flask.subprocess, "Popen", fake_popen)
+ monkeypatch.chdir(tmp_path)
+
+ scenario_payload = json.dumps(
+ {
+ "version": "0.2",
+ "domain": {"Lx": 0.1, "Ly": 0.1},
+ "sources": [
+ {"type": "wire", "x": 0.0, "y": 0.0, "radius": 0.002, "I": 5.0}
+ ],
+ "outputs": [
+ {
+ "type": "field_map",
+ "id": "cwd_field",
+ "path": "outputs/cwd_field.csv",
+ }
+ ],
+ }
+ ).encode("utf-8")
+
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), "scenario.json"),
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "100",
+ "outputs": "cwd_field",
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+
+ deadline = time.time() + 2
+ scenario_path: Path | None = None
+ while time.time() < deadline:
+ scenario = app_flask.manager._metadata.get("scenario_path") # type: ignore[attr-defined]
+ if isinstance(scenario, Path):
+ scenario_path = scenario
+ break
+ time.sleep(0.01)
+
+ assert scenario_path is not None
+
+ cwd_output = Path.cwd() / "outputs" / "cwd_field.csv"
+ cwd_output.parent.mkdir(parents=True, exist_ok=True)
+ cwd_output.write_text(
+ "x,y,Bx,By,Bmag\n"
+ "0.0,0.0,0.1,0.0,0.1\n"
+ "0.05,0.0,0.1,0.0,0.1\n"
+ "0.0,0.05,0.1,0.0,0.1\n"
+ "0.05,0.05,0.1,0.0,0.1\n",
+ encoding="utf-8",
+ )
+
+ stdout_queue.put("Frame 0: wrote field_map 'cwd_field' to \"outputs/cwd_field.csv\"\n")
+ stdout_queue.put("Finished\n")
+ stdout_queue.put(None)
+
+ events = []
+ deadline = time.time() + 3
+ while time.time() < deadline:
+ try:
+ item = app_flask.manager.queue.get(timeout=0.1)
+ events.append(item)
+ if item.get("complete"):
+ break
+ except queue.Empty:
+ pass
+
+ visualization_events = [evt for evt in events if evt.get("visualization")]
+ assert visualization_events, "Expected visualization update for process-cwd outputs"
+
+ completion_event = next(evt for evt in events if evt.get("complete"))
+ downloads = completion_event.get("downloads", [])
+ assert any(entry.get("filename") == cwd_output.name for entry in downloads)
+
+ last_result = app_flask.manager.get_last_result()
+ field_map_path = Path(last_result.get("field_map", ""))
+ assert field_map_path.exists()
+ assert field_map_path == cwd_output
+
+
+def test_cli_only_field_output_tracked(monkeypatch, tmp_path, client):
+ stdout_queue: "queue.Queue[str | None]" = queue.Queue()
+
+ class DummyStdout:
+ def __iter__(self):
+ return self
+
+ def __next__(self):
+ item = stdout_queue.get()
+ if item is None:
+ raise StopIteration
+ return item
+
+ class DummyProcess:
+ def __init__(self) -> None:
+ self.stdout = DummyStdout()
+ self.returncode = 0
+
+ def wait(self) -> int:
+ return self.returncode
+
+ def terminate(self) -> None:
+ self.returncode = 143
+
+ def fake_popen(cmd, **kwargs): # noqa: ANN001 - signature mirrors subprocess
+ return DummyProcess()
+
+ monkeypatch.setattr(app_flask.subprocess, "Popen", fake_popen)
+ monkeypatch.chdir(tmp_path)
+
+ scenario_payload = json.dumps(
+ {
+ "version": "0.2",
+ "domain": {"Lx": 0.1, "Ly": 0.1},
+ "sources": [
+ {"type": "wire", "x": 0.0, "y": 0.0, "radius": 0.001, "I": 10.0}
+ ],
+ "outputs": [],
+ }
+ ).encode("utf-8")
+
+ response = client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), "cli_only.json"),
+ "solver": "cg",
+ "tol": "1e-6",
+ "max_iters": "20",
+ "outputs": "cli_field",
+ },
+ content_type="multipart/form-data",
+ follow_redirects=False,
+ )
+
+ assert response.status_code == 302
+
+ deadline = time.time() + 2
+ scenario_path: Path | None = None
+ while time.time() < deadline:
+ scenario = app_flask.manager._metadata.get("scenario_path") # type: ignore[attr-defined]
+ if isinstance(scenario, Path):
+ scenario_path = scenario
+ break
+ time.sleep(0.01)
+
+ assert scenario_path is not None
+
+ output_path = Path.cwd() / "outputs" / "cli_field.csv"
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ "x,y,Bx,By,Bmag\n"
+ "0.0,0.0,0.1,0.0,0.1\n"
+ "0.0,0.05,0.1,0.0,0.1\n"
+ "0.05,0.0,0.1,0.0,0.1\n"
+ "0.05,0.05,0.1,0.0,0.1\n",
+ encoding="utf-8",
+ )
+
+ stdout_queue.put("Frame 0: wrote field_map 'cli_field' to \"outputs/cli_field.csv\"\n")
+ stdout_queue.put("Finished\n")
+ stdout_queue.put(None)
+
+ events = []
+ deadline = time.time() + 3
+ while time.time() < deadline:
+ try:
+ item = app_flask.manager.queue.get(timeout=0.1)
+ events.append(item)
+ if item.get("complete"):
+ break
+ except queue.Empty:
+ pass
+
+ visualization_events = [evt for evt in events if evt.get("visualization")]
+ assert visualization_events, "Expected a visualisation event for CLI-only outputs"
+ assert any(
+ evt["visualization"].get("sequence_index") is not None for evt in visualization_events
+ )
+
+ completion_event = next(evt for evt in events if evt.get("complete"))
+ downloads = completion_event.get("downloads", [])
+ assert any(entry.get("filename") == output_path.name for entry in downloads)
+
+ last_result = app_flask.manager.get_last_result()
+ frames = last_result.get("frames")
+ assert isinstance(frames, list) and frames, "Expected recorded frames in last_result"
+ assert Path(last_result.get("field_map", "")).exists()
+
+
+def test_project_scenario_download(client):
+ payload = json.dumps({"version": "0.2", "domain": {"Lx": 0.1, "Ly": 0.1}, "sources": []}).encode("utf-8")
+
+ client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(payload), "download.json"),
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ )
+
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+
+ assert project_id in app_flask.PROJECTS
+ scenario_path = app_flask.PROJECTS[project_id]["scenario_path"]
+ response = client.get("/project/scenario")
+ assert response.status_code == 200
+ assert response.data == scenario_path.read_bytes()
+ assert "download" in response.headers.get("Content-Disposition", "")
+
+
+def test_project_reset_clears_state(client):
+ payload = json.dumps({"version": "0.2", "domain": {"Lx": 0.1, "Ly": 0.1}, "sources": []}).encode("utf-8")
+
+ client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(payload), "reset.json"),
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ )
+
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+
+ scenario_path = app_flask.PROJECTS[project_id]["scenario_path"]
+ assert scenario_path.exists()
+
+ response = client.post("/project/reset")
+ assert response.status_code == 302
+ assert "notice=Project+cleared." in response.headers.get("Location", "")
+
+ with client.session_transaction() as session:
+ assert "project_id" not in session
+
+ assert project_id not in app_flask.PROJECTS
+ assert not scenario_path.exists()
+
+
+def test_project_reset_requires_idle(client):
+ app_flask.manager._running = True # type: ignore[attr-defined]
+ try:
+ response = client.post("/project/reset")
+ assert response.status_code == 302
+ assert "error=Stop+the+running+simulation" in response.headers.get("Location", "")
+ finally:
+ app_flask.manager._running = False # type: ignore[attr-defined]
+
+
+def test_visualization_route_rejects_invalid_params(visualization_ready, client):
+ response = client.get("/visualization.png", query_string={"vector": "bad"})
+ assert response.status_code == 400
+
+ response = client.get("/visualization.png", query_string={"skip": "0"})
+ assert response.status_code == 400
+
+ response = client.get("/visualization.png", query_string={"scale": "bad"})
+ assert response.status_code == 400
+
+
+def test_visualization_route_accepts_streamlines(visualization_ready, client):
+ response = client.get(
+ "/visualization.png",
+ query_string={
+ "vector": "linear",
+ "skip": "2",
+ "scale": "log",
+ "boundaries": "0",
+ "stream": "1",
+ "log_floor": "1e-9",
+ "vector_floor": "1e-9",
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.mimetype == "image/png"
+ assert b"".join(response.response)
+
+
def test_stop_route_terminates_process(client):
class DummyProcess:
def __init__(self) -> None:
@@ -121,3 +747,243 @@ def test_progress_stream_emits_payloads(client):
assert b"hello" in body
assert b"done" in body
+
+
+def test_dxf_upload_preview_and_delete(client, tmp_path):
+ doc = ezdxf.new("R2010")
+ msp = doc.modelspace()
+ msp.add_lwpolyline([(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)], dxfattribs={"layer": "domain"})
+ msp.add_circle((0.5, 0.5), 0.1, dxfattribs={"layer": "wire"})
+ dxf_path = tmp_path / "fixture.dxf"
+ doc.saveas(dxf_path)
+
+ with dxf_path.open("rb") as handle:
+ response = client.post(
+ "/dxf/upload",
+ data={"dxf_files": [(handle, "fixture.dxf")]},
+ content_type="multipart/form-data",
+ )
+
+ assert response.status_code == 302
+
+ with client.session_transaction() as session:
+ project_id = session.get("project_id")
+
+ assert project_id in app_flask.PROJECTS
+ project = app_flask.PROJECTS[project_id]
+ assert project["dxf_files"], "DXF uploads should register with the project"
+
+ entry_id, entry = next(iter(project["dxf_files"].items()))
+ preview_path = project.get("dxf_preview_path")
+ assert isinstance(preview_path, Path) and preview_path.exists()
+
+ original_version = project.get("dxf_preview_version")
+ layer_form_data = {"project_id": project_id}
+ for layer in entry["layers"]:
+ form_id = layer["form_id"]
+ category = "material" if layer["name"].lower() == "domain" else "wire"
+ layer_form_data[f"layer-{form_id}-category"] = category
+ if layer["name"].lower() == "domain":
+ # Unselect the boundary to exercise deselection logic.
+ continue
+ layer_form_data[f"layer-{form_id}-selected"] = "on"
+
+ response = client.post(
+ f"/dxf/{entry_id}/layers",
+ data=layer_form_data,
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ updated_entry = app_flask.PROJECTS[project_id]["dxf_files"][entry_id]
+ assert any(layer["selected"] for layer in updated_entry["layers"])
+ assert original_version != app_flask.PROJECTS[project_id].get("dxf_preview_version")
+
+ response = client.post(
+ f"/dxf/{entry_id}/delete",
+ data={"project_id": project_id},
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ assert entry_id not in app_flask.PROJECTS[project_id]["dxf_files"]
+
+
+def test_project_export_dxf_generates_archive(client, tmp_path):
+ scenario_payload = json.dumps(
+ {
+ "version": "0.2",
+ "name": "exportable",
+ "domain": {"Lx": 0.2, "Ly": 0.1},
+ "sources": [
+ {"type": "wire", "x": 0.0, "y": 0.0, "radius": 0.01, "I": 10.0},
+ ],
+ "regions": [
+ {
+ "type": "polygon",
+ "material": "iron",
+ "points": [[-0.08, -0.04], [0.08, -0.04], [0.08, 0.04], [-0.08, 0.04]],
+ }
+ ],
+ "magnets": [],
+ }
+ ).encode("utf-8")
+
+ client.post(
+ "/upload",
+ data={
+ "geometry_file": (io.BytesIO(scenario_payload), "export.json"),
+ "action": "preview",
+ },
+ content_type="multipart/form-data",
+ )
+
+ response = client.get("/project/export_dxf")
+
+ assert response.status_code == 200
+ assert response.mimetype in {"application/zip", "application/octet-stream"}
+ payload = b"".join(response.response)
+ assert payload, "Expected non-empty archive payload"
+
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
+ names = set(archive.namelist())
+ assert {"domain.dxf"}.issubset(names)
+
+
+def test_update_materials_replaces_palette(client):
+ project_id = _create_project(client, name="materials_case")
+
+ response = client.post(
+ "/workspace/materials",
+ data={
+ "project_id": project_id,
+ "materials-count": "2",
+ "material-0-name": "air",
+ "material-0-mu": "1.0",
+ "material-1-name": "iron",
+ "material-1-mu": "1500",
+ "material-1-sigma": "6.2e6",
+ },
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ project = app_flask.PROJECTS[project_id]
+ palette = project["spec"].get("materials")
+ assert isinstance(palette, list)
+ assert palette[1]["name"] == "iron"
+ assert palette[1]["sigma"] == pytest.approx(6.2e6)
+ scenario_path = project["scenario_path"]
+ disk_spec = json.loads(Path(scenario_path).read_text())
+ assert disk_spec["materials"][1]["mu_r"] == 1500
+
+
+def test_update_windings_generates_sources_from_dxf_layers(client, tmp_path):
+ project_id = _create_project(client, name="windings_case")
+
+ doc = ezdxf.new("R2010")
+ msp = doc.modelspace()
+ msp.add_lwpolyline([(0, 0), (0.01, 0), (0.01, 0.01), (0, 0.01), (0, 0)], dxfattribs={"layer": "coil_a"})
+ msp.add_lwpolyline([(0.02, 0), (0.03, 0), (0.03, 0.01), (0.02, 0.01), (0.02, 0)], dxfattribs={"layer": "coil_b"})
+ dxf_path = tmp_path / "windings_fixture.dxf"
+ doc.saveas(dxf_path)
+
+ with dxf_path.open("rb") as handle:
+ upload_response = client.post(
+ "/dxf/upload",
+ data={"project_id": project_id, "dxf_files": [(handle, dxf_path.name)]},
+ content_type="multipart/form-data",
+ )
+
+ assert upload_response.status_code == 302
+ project = app_flask.PROJECTS[project_id]
+ entry_id, entry = next(iter(project["dxf_files"].items()))
+
+ layer_form = {"project_id": project_id}
+ for layer in entry["layers"]:
+ layer_form[f"layer-{layer['form_id']}-selected"] = "on"
+ layer_form[f"layer-{layer['form_id']}-category"] = "wire"
+
+ layer_response = client.post(
+ f"/dxf/{entry_id}/layers",
+ data=layer_form,
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert layer_response.status_code == 302
+ layer_token = f"{entry_id}::{entry['layers'][0]['name']}"
+ form_data = MultiDict(
+ [
+ ("project_id", project_id),
+ ("winding-count", "1"),
+ ("winding-0-name", "Phase A"),
+ ("winding-0-phase", "A"),
+ ("winding-0-turns", "120"),
+ ("winding-0-fill", "0.75"),
+ ("winding-0-orientation", "1"),
+ ("winding-0-layers", layer_token),
+ ]
+ )
+
+ response = client.post(
+ "/workspace/windings",
+ data=form_data,
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ project = app_flask.PROJECTS[project_id]
+ windings = project["windings"]
+ assert len(windings) == 1
+ assert windings[0]["phase"] == "A"
+ sources = project["spec"].get("sources")
+ assert sources and sources[0]["type"] == "current_region"
+
+
+def test_update_timeline_balanced_mode_updates_spec(client):
+ project_id = _create_project(client, name="timeline_case")
+ response = client.post(
+ "/workspace/timeline",
+ data={
+ "project_id": project_id,
+ "timeline-mode": "balanced",
+ "timeline-amplitude": "25",
+ "timeline-frequency": "60",
+ "timeline-steps": "12",
+ "timeline-cycles": "2",
+ "timeline-phase-offset": "0",
+ "timeline-sequence": "ABC",
+ "timeline-dc-offset": "1.5",
+ },
+ content_type="application/x-www-form-urlencoded",
+ )
+
+ assert response.status_code == 302
+ project = app_flask.PROJECTS[project_id]
+ spec = project["spec"]
+ timeline = spec.get("timeline")
+ assert isinstance(timeline, list)
+ assert len(timeline) == 24
+ assert "phase_currents" in timeline[0]
+ transient = spec.get("transient")
+ assert transient["n_steps"] == len(timeline)
+ assert project["timeline_mode"] == "balanced"
+
+
+def test_download_serves_staged_result_file(client, tmp_path):
+ results_dir = Path(app_flask.app.config["RESULTS_FOLDER"])
+ external = tmp_path / "field.csv"
+ external.parent.mkdir(parents=True, exist_ok=True)
+ external.write_text("x,y\n0,0\n", encoding="utf-8")
+
+ staged = app_flask._stage_result_file(external, results_dir)
+ assert staged.exists()
+
+ response = client.get(
+ "/download",
+ query_string={"category": "result", "filename": staged.name},
+ )
+
+ assert response.status_code == 200
+ payload = b"".join(response.response)
+ assert b"x,y" in payload