setupEM 0.5.1__tar.gz → 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {setupem-0.5.1/src/setupEM.egg-info → setupem-0.6.1}/PKG-INFO +2 -1
- {setupem-0.5.1 → setupem-0.6.1}/pyproject.toml +2 -1
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/__init__.py +1 -1
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/palace_results.py +18 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/result_viewer.py +30 -5
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setupEM.py +302 -59
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setupThermal.py +58 -4
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setup_common.py +92 -8
- setupem-0.6.1/src/setupEM/thermal_results.py +175 -0
- {setupem-0.5.1 → setupem-0.6.1/src/setupEM.egg-info}/PKG-INFO +2 -1
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/SOURCES.txt +1 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/requires.txt +1 -0
- {setupem-0.5.1 → setupem-0.6.1}/LICENSE +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/README.md +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/README_pypi.md +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/setup.cfg +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/__main__.py +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_100um.xml +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um.xml +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_nosub.xml +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/momentum_import.py +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/stackupEditor.py +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/stackup_writer.py +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/dependency_links.txt +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/entry_points.txt +0 -0
- {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: setupEM
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Python tool for configuration of gds2palace workflow with GUI.
|
|
5
5
|
Author-email: Volker Muehlhaus <volker@muehlhaus.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -15,6 +15,7 @@ Requires-Dist: scikit-rf
|
|
|
15
15
|
Requires-Dist: matplotlib
|
|
16
16
|
Requires-Dist: numpy
|
|
17
17
|
Requires-Dist: gdspy
|
|
18
|
+
Requires-Dist: meshio
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# Python GUI for gds2palace
|
|
@@ -27,6 +27,7 @@ import csv
|
|
|
27
27
|
import re
|
|
28
28
|
import math
|
|
29
29
|
import cmath
|
|
30
|
+
import glob
|
|
30
31
|
|
|
31
32
|
_ITERATION_RE = re.compile(r'^iteration(\d+)$')
|
|
32
33
|
|
|
@@ -47,6 +48,23 @@ def find_output_dir(run_path, model_basename):
|
|
|
47
48
|
return os.path.normpath(os.path.join(run_path, output_rel))
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def find_paraview_files(run_path, model_basename):
|
|
52
|
+
"""All ParaView collection files under this run's Palace output directory, found
|
|
53
|
+
recursively. Palace's own field-dump feature (triggered by fdump/SaveStep) writes
|
|
54
|
+
its category/excitation folder structure without gds2palace's control - confirmed
|
|
55
|
+
(on two Palace versions) to only keep the last-solved excitation's Cycle files, so
|
|
56
|
+
no fixed filename or count can be assumed here. Falls back to loose .vtu if no .pvd
|
|
57
|
+
exists yet. Empty list if the output directory doesn't exist / has nothing yet.
|
|
58
|
+
"""
|
|
59
|
+
output_dir = find_output_dir(run_path, model_basename)
|
|
60
|
+
if not os.path.isdir(output_dir):
|
|
61
|
+
return []
|
|
62
|
+
pvd_files = sorted(glob.glob(os.path.join(output_dir, '**', '*.pvd'), recursive=True))
|
|
63
|
+
if pvd_files:
|
|
64
|
+
return pvd_files
|
|
65
|
+
return sorted(glob.glob(os.path.join(output_dir, '**', '*.vtu'), recursive=True))
|
|
66
|
+
|
|
67
|
+
|
|
50
68
|
def _read_palace_json(dir_path):
|
|
51
69
|
path = os.path.join(dir_path, 'palace.json')
|
|
52
70
|
if not os.path.isfile(path):
|
|
@@ -205,6 +205,32 @@ def find_touchstone_files(target_dir):
|
|
|
205
205
|
return sorted(matches)
|
|
206
206
|
|
|
207
207
|
|
|
208
|
+
# AWS Palace adaptive mesh refinement writes one result snapshot per
|
|
209
|
+
# iteration<N>/ subfolder alongside the final, fully-refined result in the
|
|
210
|
+
# parent directory itself (see _ITERATION_RE in palace_results.py, reverse
|
|
211
|
+
# engineered from real Palace output) - same convention, applied per path
|
|
212
|
+
# component so it matches regardless of how deep target_dir's own scan went.
|
|
213
|
+
_AMR_ITERATION_DIR_RE = re.compile(r'^iteration\d+$')
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_amr_iteration_snapshot(path):
|
|
217
|
+
"""True if path sits inside an AMR "iterationN" output folder, i.e. it's
|
|
218
|
+
a per-iteration snapshot rather than the final result."""
|
|
219
|
+
parts = os.path.normpath(path).split(os.sep)
|
|
220
|
+
return any(_AMR_ITERATION_DIR_RE.match(part) for part in parts)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def pick_final_result_file(paths):
|
|
224
|
+
"""Given a list of touchstone file paths, prefer the final result (any
|
|
225
|
+
path with no "iterationN" component) over AMR per-iteration snapshots;
|
|
226
|
+
break ties (or fall back, if every path is a snapshot) by newest mtime.
|
|
227
|
+
Returns None for an empty list."""
|
|
228
|
+
if not paths:
|
|
229
|
+
return None
|
|
230
|
+
final_candidates = [p for p in paths if not is_amr_iteration_snapshot(p)] or paths
|
|
231
|
+
return max(final_candidates, key=os.path.getmtime)
|
|
232
|
+
|
|
233
|
+
|
|
208
234
|
# ------------------------------------------------------------------
|
|
209
235
|
# Result Viewer window
|
|
210
236
|
# ------------------------------------------------------------------
|
|
@@ -364,13 +390,12 @@ class ResultViewerWindow(QDialog):
|
|
|
364
390
|
item.setFlags(Qt.NoItemFlags)
|
|
365
391
|
self.file_list.addTopLevelItem(item)
|
|
366
392
|
else:
|
|
367
|
-
# drop checked paths that no longer exist; auto-check the
|
|
368
|
-
#
|
|
369
|
-
# isn't blank
|
|
393
|
+
# drop checked paths that no longer exist; auto-check the final
|
|
394
|
+
# result (preferring it over any AMR per-iteration snapshot) if
|
|
395
|
+
# nothing is checked (e.g. first open), so the window isn't blank
|
|
370
396
|
self._checked_paths &= set(self._master_files)
|
|
371
397
|
if not self._checked_paths:
|
|
372
|
-
|
|
373
|
-
self._checked_paths = {newest}
|
|
398
|
+
self._checked_paths = {pick_final_result_file(self._master_files)}
|
|
374
399
|
|
|
375
400
|
# group by each file's immediate parent directory (relative to
|
|
376
401
|
# target_dir), not a Palace/Elmer-specific convention like
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
########################################################################
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
import sys, json, os, pathlib, ast, webbrowser, argparse, shutil, re
|
|
20
|
+
import sys, json, os, pathlib, ast, webbrowser, argparse, shutil, re, glob
|
|
21
21
|
import numpy as np
|
|
22
22
|
import importlib.metadata
|
|
23
23
|
import importlib.util
|
|
@@ -55,7 +55,7 @@ if __package__ in (None, ""):
|
|
|
55
55
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
56
56
|
epsilon_to_color, default_stackup_dielectric_label, default_stackup_metal_label,
|
|
57
57
|
)
|
|
58
|
-
from palace_results import build_results_summary, find_output_dir
|
|
58
|
+
from palace_results import build_results_summary, find_output_dir, find_paraview_files
|
|
59
59
|
else:
|
|
60
60
|
from .setup_common import (
|
|
61
61
|
EDIT_STYLE_OPTIONAL, EDIT_STYLE_REQUIRED, COMBO_STYLE_REQUIRED, COMBO_STYLE_OPTIONAL,
|
|
@@ -63,7 +63,7 @@ else:
|
|
|
63
63
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
64
64
|
epsilon_to_color, default_stackup_dielectric_label, default_stackup_metal_label,
|
|
65
65
|
)
|
|
66
|
-
from .palace_results import build_results_summary, find_output_dir
|
|
66
|
+
from .palace_results import build_results_summary, find_output_dir, find_paraview_files
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
'''
|
|
@@ -244,6 +244,8 @@ class FrequenciesTab(QWidget):
|
|
|
244
244
|
saved_values ["fdump"] = ast.literal_eval('['+text+']')
|
|
245
245
|
else:
|
|
246
246
|
saved_values.pop("fdump",None)
|
|
247
|
+
# "View fields in Paraview" is only relevant once fdump is set
|
|
248
|
+
self.MainWindow.create_model_tab._update_paraview_button_visibility()
|
|
247
249
|
|
|
248
250
|
# if fstart == fstop == fdump or fstart == fstop == fstep, then remove fstart, fstop
|
|
249
251
|
if fstart is not None and fstop is not None and fstart == fstop:
|
|
@@ -644,6 +646,130 @@ class PortsTab(QWidget):
|
|
|
644
646
|
|
|
645
647
|
|
|
646
648
|
|
|
649
|
+
class RefinedCellsizeOverrideDialog(QDialog):
|
|
650
|
+
"""Popup editor for settings['refined_cellsize_override'], opened from
|
|
651
|
+
MeshTab's "Advanced..." button. Lets the user set a different mesh
|
|
652
|
+
refinement cell size for specific metal/sheet layers, instead of the
|
|
653
|
+
single global "Mesh refinement at metal edges" value. The Layer column
|
|
654
|
+
is restricted to metals_list.getallplanarmetals() (conductor/sheet
|
|
655
|
+
layers) - vias and dielectrics never produce boundary curves in
|
|
656
|
+
gds2palace's meshing code, so a name outside that list would silently
|
|
657
|
+
have no effect if it were allowed to be typed in.
|
|
658
|
+
"""
|
|
659
|
+
|
|
660
|
+
def __init__(self, parent, layer_choices, current_overrides):
|
|
661
|
+
super().__init__(parent)
|
|
662
|
+
self.setAttribute(Qt.WA_DeleteOnClose)
|
|
663
|
+
self.setWindowTitle("Mesh Refinement Overrides")
|
|
664
|
+
self.resize(420, 400)
|
|
665
|
+
self.setModal(True)
|
|
666
|
+
|
|
667
|
+
self._layer_choices = layer_choices
|
|
668
|
+
self._result = []
|
|
669
|
+
|
|
670
|
+
layout = QVBoxLayout()
|
|
671
|
+
|
|
672
|
+
info = QLabel("Override the mesh refinement cell size for specific layers\n"
|
|
673
|
+
"(all other layers keep using the default set above).")
|
|
674
|
+
layout.addWidget(info)
|
|
675
|
+
|
|
676
|
+
self.table = QTableWidget()
|
|
677
|
+
self.table.setColumnCount(3)
|
|
678
|
+
self.table.setHorizontalHeaderLabels(["Layer", "Cell size (µm)", ""])
|
|
679
|
+
self.table.verticalHeader().setVisible(False)
|
|
680
|
+
self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
|
681
|
+
header = self.table.horizontalHeader()
|
|
682
|
+
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
683
|
+
header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
|
|
684
|
+
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
|
685
|
+
layout.addWidget(self.table)
|
|
686
|
+
|
|
687
|
+
for name, value in current_overrides:
|
|
688
|
+
self._add_row(name, value)
|
|
689
|
+
|
|
690
|
+
add_row_layout = QHBoxLayout()
|
|
691
|
+
add_btn = QPushButton("+ Add Row")
|
|
692
|
+
add_btn.clicked.connect(lambda: self._add_row())
|
|
693
|
+
add_row_layout.addWidget(add_btn)
|
|
694
|
+
add_row_layout.addStretch()
|
|
695
|
+
layout.addLayout(add_row_layout)
|
|
696
|
+
|
|
697
|
+
button_layout = QHBoxLayout()
|
|
698
|
+
button_layout.addStretch()
|
|
699
|
+
ok_btn = QPushButton("OK")
|
|
700
|
+
ok_btn.clicked.connect(self._on_ok)
|
|
701
|
+
button_layout.addWidget(ok_btn)
|
|
702
|
+
cancel_btn = QPushButton("Cancel")
|
|
703
|
+
cancel_btn.clicked.connect(self.reject)
|
|
704
|
+
button_layout.addWidget(cancel_btn)
|
|
705
|
+
layout.addLayout(button_layout)
|
|
706
|
+
|
|
707
|
+
self.setLayout(layout)
|
|
708
|
+
|
|
709
|
+
def _add_row(self, name="", value=""):
|
|
710
|
+
row = self.table.rowCount()
|
|
711
|
+
self.table.insertRow(row)
|
|
712
|
+
|
|
713
|
+
combo = QComboBox()
|
|
714
|
+
combo.setStyleSheet(COMBO_STYLE_OPTIONAL)
|
|
715
|
+
combo.addItem("")
|
|
716
|
+
combo.addItems(self._layer_choices)
|
|
717
|
+
if name and name not in self._layer_choices:
|
|
718
|
+
# tolerate a saved override for a layer no longer in the current
|
|
719
|
+
# stackup, same tolerant style as the Cellname combo box
|
|
720
|
+
combo.addItem(name)
|
|
721
|
+
if name:
|
|
722
|
+
combo.setCurrentText(name)
|
|
723
|
+
self.table.setCellWidget(row, 0, combo)
|
|
724
|
+
|
|
725
|
+
value_text = "" if value == "" else str(value)
|
|
726
|
+
self.table.setItem(row, 1, QTableWidgetItem(value_text))
|
|
727
|
+
|
|
728
|
+
remove_btn = QPushButton("✕")
|
|
729
|
+
remove_btn.setFixedWidth(28)
|
|
730
|
+
remove_btn.clicked.connect(lambda: self._remove_row_containing(remove_btn))
|
|
731
|
+
self.table.setCellWidget(row, 2, remove_btn)
|
|
732
|
+
|
|
733
|
+
def _remove_row_containing(self, button):
|
|
734
|
+
# look up the row by identity rather than a captured index, since
|
|
735
|
+
# row indices shift whenever an earlier row is removed
|
|
736
|
+
for row in range(self.table.rowCount()):
|
|
737
|
+
if self.table.cellWidget(row, 2) is button:
|
|
738
|
+
self.table.removeRow(row)
|
|
739
|
+
return
|
|
740
|
+
|
|
741
|
+
def _on_ok(self):
|
|
742
|
+
overrides = []
|
|
743
|
+
seen_layers = set()
|
|
744
|
+
for row in range(self.table.rowCount()):
|
|
745
|
+
combo = self.table.cellWidget(row, 0)
|
|
746
|
+
layer = combo.currentText().strip() if combo else ""
|
|
747
|
+
if not layer:
|
|
748
|
+
continue # blank row - not yet configured, skip silently
|
|
749
|
+
|
|
750
|
+
value_item = self.table.item(row, 1)
|
|
751
|
+
value_text = value_item.text().strip() if value_item else ""
|
|
752
|
+
try:
|
|
753
|
+
value = float(value_text)
|
|
754
|
+
if value <= 0:
|
|
755
|
+
raise ValueError
|
|
756
|
+
except ValueError:
|
|
757
|
+
QMessageBox.warning(self, "Error", f"Not a valid cell size for layer '{layer}'")
|
|
758
|
+
return
|
|
759
|
+
|
|
760
|
+
if layer in seen_layers:
|
|
761
|
+
QMessageBox.warning(self, "Error", f"Layer '{layer}' is selected more than once")
|
|
762
|
+
return
|
|
763
|
+
seen_layers.add(layer)
|
|
764
|
+
overrides.append([layer, value])
|
|
765
|
+
|
|
766
|
+
self._result = overrides
|
|
767
|
+
self.accept()
|
|
768
|
+
|
|
769
|
+
def get_overrides(self):
|
|
770
|
+
return self._result
|
|
771
|
+
|
|
772
|
+
|
|
647
773
|
class MeshTab(QWidget):
|
|
648
774
|
def __init__(self, MainWindow):
|
|
649
775
|
super().__init__()
|
|
@@ -662,42 +788,44 @@ class MeshTab(QWidget):
|
|
|
662
788
|
self.mesh_layout = QVBoxLayout()
|
|
663
789
|
|
|
664
790
|
self.refinement_layout = QHBoxLayout()
|
|
665
|
-
self.label2 = QLabel("Mesh refinement at metal edges")
|
|
791
|
+
self.label2 = QLabel("Mesh refinement at metal edges (µm)")
|
|
666
792
|
self.label2.setFixedWidth(label_width)
|
|
667
793
|
self.refinement_layout.addWidget(self.label2)
|
|
668
794
|
self.refinement_edit = QLineEdit("5")
|
|
669
795
|
self.refinement_edit.setFixedWidth(edit_width)
|
|
670
796
|
self.refinement_edit.setStyleSheet(EDIT_STYLE_REQUIRED)
|
|
671
797
|
self.refinement_layout.addWidget(self.refinement_edit)
|
|
672
|
-
self.
|
|
673
|
-
self.
|
|
798
|
+
self.refined_override_btn = QPushButton("Advanced...")
|
|
799
|
+
self.refined_override_btn.clicked.connect(self.open_refined_cellsize_override_dialog)
|
|
800
|
+
self.refinement_layout.addWidget(self.refined_override_btn)
|
|
674
801
|
self.refinement_layout.addStretch()
|
|
675
802
|
self.mesh_layout.addLayout(self.refinement_layout)
|
|
676
803
|
|
|
804
|
+
# in-memory copy of settings['refined_cellsize_override'] (list of
|
|
805
|
+
# [layername, value] pairs), edited via the "Advanced..." dialog;
|
|
806
|
+
# harvested into saved_values by save_values() like every other field
|
|
807
|
+
self._refined_cellsize_override = []
|
|
808
|
+
|
|
677
809
|
self.cells_lambda_layout = QHBoxLayout()
|
|
678
|
-
self.label4 = QLabel("Mesh cells per wavelength")
|
|
810
|
+
self.label4 = QLabel("Mesh cells per wavelength (min 10)")
|
|
679
811
|
self.label4.setFixedWidth(label_width)
|
|
680
812
|
self.cells_lambda_layout.addWidget(self.label4)
|
|
681
813
|
self.cells_lambda_edit = QLineEdit("10")
|
|
682
814
|
self.cells_lambda_edit.setFixedWidth(edit_width)
|
|
683
815
|
self.cells_lambda_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
|
|
684
816
|
self.cells_lambda_layout.addWidget(self.cells_lambda_edit)
|
|
685
|
-
self.label5 = QLabel(" (min 10)")
|
|
686
|
-
self.cells_lambda_layout.addWidget(self.label5)
|
|
687
817
|
self.cells_lambda_layout.addStretch()
|
|
688
818
|
self.mesh_layout.addLayout(self.cells_lambda_layout)
|
|
689
819
|
|
|
690
820
|
|
|
691
821
|
self.cells_maxsize_layout = QHBoxLayout()
|
|
692
|
-
self.label6 = QLabel("Mesh cell maximum size absolute")
|
|
822
|
+
self.label6 = QLabel("Mesh cell maximum size absolute (µm)")
|
|
693
823
|
self.label6.setFixedWidth(label_width)
|
|
694
824
|
self.cells_maxsize_layout.addWidget(self.label6)
|
|
695
825
|
self.cells_maxsize_edit = QLineEdit("100")
|
|
696
826
|
self.cells_maxsize_edit.setFixedWidth(edit_width)
|
|
697
827
|
self.cells_maxsize_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
|
|
698
828
|
self.cells_maxsize_layout.addWidget(self.cells_maxsize_edit)
|
|
699
|
-
self.label5 = QLabel(" µm ")
|
|
700
|
-
self.cells_maxsize_layout.addWidget(self.label5)
|
|
701
829
|
self.cells_maxsize_layout.addStretch()
|
|
702
830
|
self.mesh_layout.addLayout(self.cells_maxsize_layout)
|
|
703
831
|
|
|
@@ -916,6 +1044,22 @@ class MeshTab(QWidget):
|
|
|
916
1044
|
self.setLayout(self.main_layout)
|
|
917
1045
|
|
|
918
1046
|
|
|
1047
|
+
def _update_refined_override_button_label(self):
|
|
1048
|
+
n = len(self._refined_cellsize_override)
|
|
1049
|
+
self.refined_override_btn.setText(f"Advanced... ({n})" if n > 0 else "Advanced...")
|
|
1050
|
+
|
|
1051
|
+
def open_refined_cellsize_override_dialog(self):
|
|
1052
|
+
metals_list = self.MainWindow.metals_list
|
|
1053
|
+
if metals_list is None:
|
|
1054
|
+
QMessageBox.warning(self, "Error", "Load a GDSII file and XML stackup first")
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
layer_choices = [metal.name for metal in metals_list.getallplanarmetals()]
|
|
1058
|
+
dialog = RefinedCellsizeOverrideDialog(self, layer_choices, self._refined_cellsize_override)
|
|
1059
|
+
if dialog.exec() == QDialog.Accepted:
|
|
1060
|
+
self._refined_cellsize_override = dialog.get_overrides()
|
|
1061
|
+
self._update_refined_override_button_label()
|
|
1062
|
+
|
|
919
1063
|
def on_meshorder_changed(self, value):
|
|
920
1064
|
# callback when mesh order changed, so that we can show/hide edit fields
|
|
921
1065
|
try:
|
|
@@ -952,6 +1096,7 @@ class MeshTab(QWidget):
|
|
|
952
1096
|
self.refinement_edit.setText("5")
|
|
953
1097
|
return False
|
|
954
1098
|
saved_values ["refined_cellsize"] = float(value)
|
|
1099
|
+
saved_values ["refined_cellsize_override"] = self._refined_cellsize_override
|
|
955
1100
|
|
|
956
1101
|
saved_values ["order"] = self.mesh_order_box.currentIndex()+1
|
|
957
1102
|
|
|
@@ -1053,6 +1198,8 @@ class MeshTab(QWidget):
|
|
|
1053
1198
|
|
|
1054
1199
|
def load_values(self):
|
|
1055
1200
|
self.refinement_edit.setText(str(saved_values.get("refined_cellsize","5")))
|
|
1201
|
+
self._refined_cellsize_override = list(saved_values.get("refined_cellsize_override", []))
|
|
1202
|
+
self._update_refined_override_button_label()
|
|
1056
1203
|
self.cells_lambda_edit.setText(str(saved_values.get("cells_per_wavelength","10")))
|
|
1057
1204
|
self.cells_maxsize_edit.setText(str(saved_values.get("meshsize_max","100")))
|
|
1058
1205
|
self.AMR_iterations_edit.setText(str(saved_values.get("adaptive_mesh_iterations","0")))
|
|
@@ -1080,17 +1227,23 @@ class MeshTab(QWidget):
|
|
|
1080
1227
|
self.airaround_edit.setText(saved_values.get("margin","200"))
|
|
1081
1228
|
self.airaround_box.setCurrentIndex(0)
|
|
1082
1229
|
else:
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1230
|
+
# native JSON round-trip stores this as a real list of floats;
|
|
1231
|
+
# .py import stores it as a comma-separated string instead
|
|
1232
|
+
if isinstance(air, list):
|
|
1233
|
+
air_as_list = [str(v) for v in air]
|
|
1234
|
+
elif "," in str(air):
|
|
1235
|
+
air_as_list = [v.strip() for v in str(air).split(',')]
|
|
1236
|
+
else:
|
|
1237
|
+
air_as_list = None
|
|
1238
|
+
|
|
1239
|
+
if air_as_list and len(air_as_list) == 6:
|
|
1240
|
+
self.airaround_box.setCurrentIndex(1)
|
|
1241
|
+
self.airxmin_edit.setText(air_as_list[0])
|
|
1242
|
+
self.airxmax_edit.setText(air_as_list[1])
|
|
1243
|
+
self.airymin_edit.setText(air_as_list[2])
|
|
1244
|
+
self.airymax_edit.setText(air_as_list[3])
|
|
1245
|
+
self.airzmin_edit.setText(air_as_list[4])
|
|
1246
|
+
self.airzmax_edit.setText(air_as_list[5])
|
|
1094
1247
|
else:
|
|
1095
1248
|
# we have air_around defined as a single value
|
|
1096
1249
|
self.airaround_box.setCurrentIndex(0)
|
|
@@ -1134,13 +1287,26 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1134
1287
|
# up exactly with Preview/Create Mesh/Start Simulation above, in the same
|
|
1135
1288
|
# Actions group.
|
|
1136
1289
|
row = self.buttons_grid.rowCount()
|
|
1137
|
-
self.view_results_btn = QPushButton("📈 View
|
|
1290
|
+
self.view_results_btn = QPushButton("📈 View S-Parameters...")
|
|
1138
1291
|
self.view_results_btn.clicked.connect(self.MainWindow.open_result_viewer)
|
|
1139
1292
|
self.buttons_grid.addWidget(self.view_results_btn, row, 0)
|
|
1140
1293
|
self.model_fit_btn = QPushButton("🧩 Model Fit...")
|
|
1141
1294
|
self.model_fit_btn.clicked.connect(self.open_model_fit)
|
|
1142
1295
|
self.buttons_grid.addWidget(self.model_fit_btn, row, 1)
|
|
1143
1296
|
|
|
1297
|
+
# "View fields in Paraview" opens field-dump data (Palace fdump / Elmer EM
|
|
1298
|
+
# fields*.vtu), a separate row since it's independent of the S-parameter
|
|
1299
|
+
# viewer/model fit above. Only meaningful when fdump is set (otherwise there's
|
|
1300
|
+
# never any field data to open), so it's hidden rather than shown greyed-out -
|
|
1301
|
+
# kept in sync with saved_values['fdump'] via _update_paraview_button_visibility(),
|
|
1302
|
+
# called from here, from load_values() (project/model import), and from
|
|
1303
|
+
# FrequenciesTab.save_values() (live edits to the fdump field).
|
|
1304
|
+
row = self.buttons_grid.rowCount()
|
|
1305
|
+
self.paraview_btn = QPushButton("🖼️ View fields in Paraview...")
|
|
1306
|
+
self.paraview_btn.clicked.connect(self.launch_paraview)
|
|
1307
|
+
self.buttons_grid.addWidget(self.paraview_btn, row, 0)
|
|
1308
|
+
self._update_paraview_button_visibility()
|
|
1309
|
+
|
|
1144
1310
|
def open_model_fit(self):
|
|
1145
1311
|
SNP2LE_URL = "https://github.com/iic-jku/snp2le"
|
|
1146
1312
|
|
|
@@ -1171,9 +1337,9 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1171
1337
|
# local import: only needed once snp2le is actually launched, same lazy-import
|
|
1172
1338
|
# style used by open_result_viewer() for its own heavy imports.
|
|
1173
1339
|
if __package__ in (None, ""):
|
|
1174
|
-
from result_viewer import find_touchstone_files
|
|
1340
|
+
from result_viewer import find_touchstone_files, is_amr_iteration_snapshot
|
|
1175
1341
|
else:
|
|
1176
|
-
from .result_viewer import find_touchstone_files
|
|
1342
|
+
from .result_viewer import find_touchstone_files, is_amr_iteration_snapshot
|
|
1177
1343
|
|
|
1178
1344
|
if self.MainWindow.PalaceMode:
|
|
1179
1345
|
run_path = saved_values['sim_path'] + "/palace_model/" + saved_values['model_basename'] + "_data"
|
|
@@ -1189,23 +1355,28 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1189
1355
|
QMessageBox.warning(self, "Model Fit",
|
|
1190
1356
|
f"No raw S-parameter result file found in {run_path}.\nRun a simulation first.")
|
|
1191
1357
|
return
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1358
|
+
|
|
1359
|
+
# adaptive mesh refinement leaves one snapshot touchstone file per
|
|
1360
|
+
# iteration<N>/ subfolder alongside the final, fully-refined result
|
|
1361
|
+
# directly in run_path - prefer that final result over the snapshots.
|
|
1362
|
+
final_candidates = [p for p in raw_files if not is_amr_iteration_snapshot(p)] or raw_files
|
|
1363
|
+
if len(final_candidates) > 1:
|
|
1364
|
+
self.log_area.appendPlainText("⚠️ Multiple raw S-parameter result files found, using the newest one:")
|
|
1365
|
+
for path in final_candidates:
|
|
1195
1366
|
self.log_area.appendPlainText(" " + path)
|
|
1196
|
-
raw_file =
|
|
1367
|
+
raw_file = max(final_candidates, key=os.path.getmtime)
|
|
1197
1368
|
|
|
1198
|
-
#
|
|
1199
|
-
#
|
|
1200
|
-
#
|
|
1201
|
-
#
|
|
1369
|
+
# naming a .sNp file on the command line opens snp2le's GUI on it directly
|
|
1370
|
+
# (see https://github.com/iic-jku/snp2le/blob/main/doc/architecture.md);
|
|
1371
|
+
# still start it in that file's directory, so any relative paths it uses
|
|
1372
|
+
# (e.g. for exporting a fit) resolve there instead of wherever setupEM
|
|
1373
|
+
# happened to be launched from.
|
|
1202
1374
|
raw_dir = os.path.dirname(raw_file)
|
|
1203
|
-
self.log_area.appendPlainText("Starting snp2le ...")
|
|
1375
|
+
self.log_area.appendPlainText(f"Starting snp2le on {raw_file} ...")
|
|
1204
1376
|
self.log_area.appendPlainText(SNP2LE_URL)
|
|
1205
|
-
self.log_area.appendPlainText(f"In the snp2le window, load: {raw_file}")
|
|
1206
1377
|
self._process_purpose = "model_fit"
|
|
1207
1378
|
self.process.setWorkingDirectory(raw_dir)
|
|
1208
|
-
self.process.start(sys.executable, ["-m", "snp2le"])
|
|
1379
|
+
self.process.start(sys.executable, ["-m", "snp2le", raw_file])
|
|
1209
1380
|
|
|
1210
1381
|
def _append_results_summary(self):
|
|
1211
1382
|
# Palace-only: parse palace.json / error-indicators.csv and append a results summary
|
|
@@ -1214,6 +1385,43 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1214
1385
|
summary = build_results_summary(run_path, saved_values['model_basename'])
|
|
1215
1386
|
self.log_area.appendPlainText("\n" + summary + "\n")
|
|
1216
1387
|
|
|
1388
|
+
def _update_paraview_button_visibility(self):
|
|
1389
|
+
self.paraview_btn.setVisible(bool(saved_values.get('fdump')))
|
|
1390
|
+
|
|
1391
|
+
def load_values(self):
|
|
1392
|
+
super().load_values()
|
|
1393
|
+
self._update_paraview_button_visibility()
|
|
1394
|
+
|
|
1395
|
+
def launch_paraview(self):
|
|
1396
|
+
if self.MainWindow.PalaceMode:
|
|
1397
|
+
run_path = saved_values['sim_path'] + "/palace_model/" + saved_values['model_basename'] + "_data"
|
|
1398
|
+
file_paths = find_paraview_files(run_path, saved_values['model_basename'])
|
|
1399
|
+
not_found = (
|
|
1400
|
+
f"⚠️ No Palace field-dump output found under "
|
|
1401
|
+
f"{find_output_dir(run_path, saved_values['model_basename'])}\n"
|
|
1402
|
+
"(set fdump to specific frequencies before running the simulation)\n"
|
|
1403
|
+
)
|
|
1404
|
+
else:
|
|
1405
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
1406
|
+
# Output File Name = File "fields" has no path prefix, so Elmer resolves it
|
|
1407
|
+
# relative to the Mesh DB directory ("mesh" under run_path) rather than
|
|
1408
|
+
# run_path itself - confirmed against a real run (same resolution mechanism
|
|
1409
|
+
# found for thermal_results.vtu). Check run_path too, defensively.
|
|
1410
|
+
search_dirs = [os.path.join(run_path, "mesh"), run_path]
|
|
1411
|
+
file_paths = []
|
|
1412
|
+
for pattern in ("fields*.pvd", "fields*.vtu"):
|
|
1413
|
+
for d in search_dirs:
|
|
1414
|
+
file_paths = sorted(glob.glob(os.path.join(d, pattern)))
|
|
1415
|
+
if file_paths:
|
|
1416
|
+
break
|
|
1417
|
+
if file_paths:
|
|
1418
|
+
break
|
|
1419
|
+
not_found = (
|
|
1420
|
+
f"⚠️ No Elmer field-dump output found under {run_path}\n"
|
|
1421
|
+
"(set fdump to specific frequencies before running the simulation)\n"
|
|
1422
|
+
)
|
|
1423
|
+
self._open_in_paraview(file_paths, not_found)
|
|
1424
|
+
|
|
1217
1425
|
def on_finished(self, exit_code, exit_status):
|
|
1218
1426
|
super().on_finished(exit_code, exit_status)
|
|
1219
1427
|
# Auto-append the results summary after a real simulation run (not after mesh creation)
|
|
@@ -1275,9 +1483,13 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1275
1483
|
|
|
1276
1484
|
Palace writes all of its results into one dedicated subdirectory (named in
|
|
1277
1485
|
config.json's Problem.Output, resolved via find_output_dir()), so that whole
|
|
1278
|
-
subtree is the deletion target. Elmer
|
|
1279
|
-
|
|
1280
|
-
|
|
1486
|
+
subtree is the deletion target. Elmer's SaveScalars/ResultOutputSolver write
|
|
1487
|
+
bare filenames (no path prefix) in their .sif config, so Elmer resolves them
|
|
1488
|
+
relative to the Mesh DB directory ("mesh" under run_path) rather than run_path
|
|
1489
|
+
itself - confirmed against real runs (same resolution mechanism found for
|
|
1490
|
+
thermal_results.vtu, and combine_snp.py itself expects "scalar_results" under
|
|
1491
|
+
a "mesh" parent and writes the resulting .sNp file there too). run_path itself
|
|
1492
|
+
is also checked, defensively, in case some variant writes there directly.
|
|
1281
1493
|
"""
|
|
1282
1494
|
if self.MainWindow.PalaceMode:
|
|
1283
1495
|
output_dir = find_output_dir(run_path, saved_values['model_basename'])
|
|
@@ -1286,12 +1498,14 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1286
1498
|
targets = [output_dir]
|
|
1287
1499
|
else:
|
|
1288
1500
|
targets = []
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1501
|
+
for search_dir in (os.path.join(run_path, "mesh"), run_path):
|
|
1502
|
+
if not os.path.isdir(search_dir):
|
|
1503
|
+
continue
|
|
1504
|
+
for fn in os.listdir(search_dir):
|
|
1505
|
+
full_path = os.path.join(search_dir, fn)
|
|
1292
1506
|
if not os.path.isfile(full_path):
|
|
1293
1507
|
continue
|
|
1294
|
-
if fn in ("scalar_results
|
|
1508
|
+
if fn in ("scalar_results", "scalar_results.names") or \
|
|
1295
1509
|
fn.startswith("fields") or re.search(r'\.s\d+p$', fn, re.IGNORECASE):
|
|
1296
1510
|
targets.append(full_path)
|
|
1297
1511
|
if not targets:
|
|
@@ -1390,17 +1604,27 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
1390
1604
|
|
|
1391
1605
|
self.log_area.appendPlainText('Setting work directory ' + run_path)
|
|
1392
1606
|
|
|
1393
|
-
#
|
|
1394
|
-
#
|
|
1395
|
-
#
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1607
|
+
# create_elmer_run_script() (util_utilities.py) writes run_elmer.bat
|
|
1608
|
+
# directly on Windows (proper .bat content - no "#!/bin/bash" shebang,
|
|
1609
|
+
# and MS-MPI's "mpiexec -n N" instead of the Linux-only "mpirun -np N"),
|
|
1610
|
+
# so no rename step is needed here any more.
|
|
1611
|
+
if saved_values.get('ELMER_MPI_THREADS', 1) > 1 and shutil.which("mpiexec") is None:
|
|
1612
|
+
self.log_area.appendPlainText(
|
|
1613
|
+
"⚠️ This model requests MPI multithreading, but 'mpiexec' was not "
|
|
1614
|
+
"found on PATH. On Windows, Elmer uses Microsoft MPI - download and "
|
|
1615
|
+
"install it from "
|
|
1616
|
+
"https://learn.microsoft.com/en-us/message-passing-interface/microsoft-mpi "
|
|
1617
|
+
"and restart setupEM, or switch to 1 thread (no multithreading) on the "
|
|
1618
|
+
"Mesh and Boundaries tab.\n"
|
|
1619
|
+
)
|
|
1620
|
+
return
|
|
1401
1621
|
self.process.setWorkingDirectory(run_path)
|
|
1402
|
-
# start simulation
|
|
1403
|
-
|
|
1622
|
+
# start simulation - full path, not just "run_elmer.bat": Windows'
|
|
1623
|
+
# CreateProcess resolves a bare relative program name against the
|
|
1624
|
+
# CALLING process's own cwd/PATH, not the child's setWorkingDirectory(),
|
|
1625
|
+
# so a bare filename here silently fails with FailedToStart even though
|
|
1626
|
+
# setWorkingDirectory() is set correctly.
|
|
1627
|
+
self.process.start(os.path.join(run_path, "run_elmer.bat"))
|
|
1404
1628
|
else:
|
|
1405
1629
|
# Linux
|
|
1406
1630
|
self.log_area.appendPlainText('Setting work directory ' + run_path)
|
|
@@ -1516,7 +1740,7 @@ class ModelEditorTab(QWidget):
|
|
|
1516
1740
|
|
|
1517
1741
|
if forExport:
|
|
1518
1742
|
# these commands are only used within this GUI application to control gmsh
|
|
1519
|
-
ignore_list.
|
|
1743
|
+
ignore_list.extend(['preview_only','no_preview'])
|
|
1520
1744
|
|
|
1521
1745
|
for key in saved_values.keys():
|
|
1522
1746
|
if not key in special_keylist:
|
|
@@ -1739,7 +1963,9 @@ class MainWindow(MainWindowBase):
|
|
|
1739
1963
|
self.PalaceMode = False
|
|
1740
1964
|
self.ElmerMode = True
|
|
1741
1965
|
self.setWindowTitle(APP_NAME + ' Elmer')
|
|
1742
|
-
|
|
1966
|
+
# fdump now also enables Elmer EM field dumps (Exec Solver = Always), not just
|
|
1967
|
+
# Palace's SaveStep - so this group must stay visible/usable in Elmer mode too.
|
|
1968
|
+
self.frequencies_tab.dump_group.setVisible(True)
|
|
1743
1969
|
self.mesh_tab.AMR_group.setVisible(False)
|
|
1744
1970
|
self.mesh_tab.Elmer_group.setVisible(True)
|
|
1745
1971
|
|
|
@@ -1802,20 +2028,37 @@ class MainWindow(MainWindowBase):
|
|
|
1802
2028
|
def apply_native_config_data(self, data):
|
|
1803
2029
|
# update ports, they are separate from the other internal data
|
|
1804
2030
|
self.ports_tab.update_port_from_import(data.get("ports"))
|
|
2031
|
+
# restore simulator mode (not part of saved_values, see native_config_extra_struct)
|
|
2032
|
+
if data.get("elmer_mode", False):
|
|
2033
|
+
self.setElmerMode()
|
|
2034
|
+
else:
|
|
2035
|
+
self.setPalaceMode()
|
|
1805
2036
|
|
|
1806
2037
|
def apply_python_import_data(self, file_path):
|
|
1807
2038
|
# read port assignments in workflow syntax for gds2palace Python code
|
|
1808
2039
|
ports = parse_python_ports_definitions(file_path)
|
|
1809
2040
|
self.ports_tab.update_port_from_import(ports)
|
|
1810
2041
|
|
|
1811
|
-
#
|
|
1812
|
-
|
|
2042
|
+
# Elmer/Palace mode isn't captured by the general settings-dict import
|
|
2043
|
+
# (parse_assignments() skips lines whose value contains "settings", and
|
|
2044
|
+
# 'elmer' isn't in import_mapping anyway), so detect it directly from the
|
|
2045
|
+
# source text instead. Two valid ways a script selects Elmer mode:
|
|
2046
|
+
# - calling simulation_setup.create_elmer(...) (what create_model_text()
|
|
2047
|
+
# itself generates, with a space before "(" - a plain "create_elmer("
|
|
2048
|
+
# substring check never matches that)
|
|
2049
|
+
# - setting settings['elmer'] = True directly and calling create_model()
|
|
2050
|
+
# itself (create_elmer() is only a thin wrapper that sets this same flag)
|
|
2051
|
+
with open(file_path) as f:
|
|
2052
|
+
text = f.read()
|
|
2053
|
+
elmer_call = re.search(r'create_elmer\s*\(', text)
|
|
2054
|
+
elmer_flag = re.search(r'settings\s*\[\s*[\'"]elmer[\'"]\s*\]\s*=\s*True\b', text, re.IGNORECASE)
|
|
2055
|
+
if elmer_call or elmer_flag:
|
|
1813
2056
|
self.setElmerMode()
|
|
1814
2057
|
else:
|
|
1815
2058
|
self.setPalaceMode()
|
|
1816
2059
|
|
|
1817
2060
|
def native_config_extra_struct(self):
|
|
1818
|
-
return {"ports": simulation_ports_to_struct(simulation_ports)}
|
|
2061
|
+
return {"ports": simulation_ports_to_struct(simulation_ports), "elmer_mode": self.ElmerMode}
|
|
1819
2062
|
|
|
1820
2063
|
def update_target_layer_choices(self, metals_list):
|
|
1821
2064
|
self.ports_tab.update_layers(metals_list)
|
|
@@ -53,12 +53,14 @@ if __package__ in (None, ""):
|
|
|
53
53
|
FileDropLineEdit, FileInputTab, PythonHighlighter, CodeEditor,
|
|
54
54
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
55
55
|
)
|
|
56
|
+
from thermal_results import build_thermal_summary, format_source_table, find_thermal_vtu
|
|
56
57
|
else:
|
|
57
58
|
from .setup_common import (
|
|
58
59
|
EDIT_STYLE_OPTIONAL, EDIT_STYLE_REQUIRED, COMBO_STYLE_REQUIRED, COMBO_STYLE_OPTIONAL,
|
|
59
60
|
FileDropLineEdit, FileInputTab, PythonHighlighter, CodeEditor,
|
|
60
61
|
VectorWidget, PopUpWindow, CreateModelTabBase, MainWindowBase,
|
|
61
62
|
)
|
|
63
|
+
from .thermal_results import build_thermal_summary, format_source_table, find_thermal_vtu
|
|
62
64
|
|
|
63
65
|
|
|
64
66
|
'''
|
|
@@ -486,7 +488,7 @@ class MeshTab(QWidget):
|
|
|
486
488
|
self.label6 = QLabel("Mesh cell maximum size")
|
|
487
489
|
self.label6.setFixedWidth(label_width)
|
|
488
490
|
self.cells_maxsize_layout.addWidget(self.label6)
|
|
489
|
-
self.cells_maxsize_edit = QLineEdit("
|
|
491
|
+
self.cells_maxsize_edit = QLineEdit("100")
|
|
490
492
|
self.cells_maxsize_edit.setFixedWidth(edit_width)
|
|
491
493
|
self.cells_maxsize_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
|
|
492
494
|
self.cells_maxsize_layout.addWidget(self.cells_maxsize_edit)
|
|
@@ -507,7 +509,7 @@ class MeshTab(QWidget):
|
|
|
507
509
|
self.label7 = QLabel("Dielectric stackup: oversize by")
|
|
508
510
|
self.label7.setFixedWidth(label_width)
|
|
509
511
|
self.margins_layout.addWidget(self.label7)
|
|
510
|
-
self.margins_edit = QLineEdit("
|
|
512
|
+
self.margins_edit = QLineEdit("100")
|
|
511
513
|
self.margins_edit.setFixedWidth(edit_width)
|
|
512
514
|
self.margins_edit.setStyleSheet(EDIT_STYLE_REQUIRED)
|
|
513
515
|
self.margins_layout.addWidget(self.margins_edit)
|
|
@@ -545,7 +547,7 @@ class MeshTab(QWidget):
|
|
|
545
547
|
value = float(self.margins_edit.text())
|
|
546
548
|
except Exception:
|
|
547
549
|
QMessageBox.warning(self, "Error", "Not a valid value for dielectric oversize margin")
|
|
548
|
-
self.margins_edit.setText("
|
|
550
|
+
self.margins_edit.setText("100")
|
|
549
551
|
return False
|
|
550
552
|
saved_values ["margin"] = float(value)
|
|
551
553
|
|
|
@@ -569,6 +571,56 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
569
571
|
are specific to this app.
|
|
570
572
|
"""
|
|
571
573
|
|
|
574
|
+
def __init__(self, MainWindow):
|
|
575
|
+
super().__init__(MainWindow)
|
|
576
|
+
|
|
577
|
+
# Tracks which action self.process is currently running, so on_finished() can tell a
|
|
578
|
+
# simulation run apart from a mesh-creation run (self.process is reused for both).
|
|
579
|
+
self._process_purpose = None
|
|
580
|
+
|
|
581
|
+
# "View in ParaView" is thermal-only (Palace/EM results are S-parameters, not a 3D
|
|
582
|
+
# field to open directly), added as its own row of the base class's buttons_grid so
|
|
583
|
+
# it lines up with Preview/Create Mesh/Start Simulation above, in the same Actions group.
|
|
584
|
+
row = self.buttons_grid.rowCount()
|
|
585
|
+
self.paraview_btn = QPushButton("🖼️ View in ParaView")
|
|
586
|
+
self.paraview_btn.clicked.connect(self.launch_paraview)
|
|
587
|
+
self.buttons_grid.addWidget(self.paraview_btn, row, 0)
|
|
588
|
+
|
|
589
|
+
def _append_thermal_results_summary(self):
|
|
590
|
+
# Parse thermal_results.dat / thermal_results.vtu and append a results summary
|
|
591
|
+
# to the log. Called from on_finished() after a real simulation run.
|
|
592
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
593
|
+
|
|
594
|
+
# Heat sources first, then constant-temperature boundaries, regardless of the
|
|
595
|
+
# order they were added in the GUI.
|
|
596
|
+
rows = []
|
|
597
|
+
for thermal_object in thermal_objects.objects:
|
|
598
|
+
if isinstance(thermal_object, simulation_setup.heatsource):
|
|
599
|
+
rows.append(["Heat source", str(thermal_object.source_layernum),
|
|
600
|
+
str(thermal_object.target_layername), f"{thermal_object.power} W"])
|
|
601
|
+
for thermal_object in thermal_objects.objects:
|
|
602
|
+
if isinstance(thermal_object, simulation_setup.constanttemp):
|
|
603
|
+
rows.append(["Const. temp", str(thermal_object.source_layernum),
|
|
604
|
+
str(thermal_object.target_layername), f"{thermal_object.temp} K"])
|
|
605
|
+
source_lines = format_source_table(rows) if rows else None
|
|
606
|
+
|
|
607
|
+
summary = build_thermal_summary(run_path, source_lines=source_lines)
|
|
608
|
+
self.log_area.appendPlainText("\n" + summary + "\n")
|
|
609
|
+
|
|
610
|
+
def on_finished(self, exit_code, exit_status):
|
|
611
|
+
super().on_finished(exit_code, exit_status)
|
|
612
|
+
# Auto-append the results summary after a real simulation run (not after mesh creation)
|
|
613
|
+
if self._process_purpose == "run_simulation":
|
|
614
|
+
self._append_thermal_results_summary()
|
|
615
|
+
|
|
616
|
+
def launch_paraview(self):
|
|
617
|
+
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
618
|
+
vtu_path = find_thermal_vtu(run_path)
|
|
619
|
+
self._open_in_paraview(
|
|
620
|
+
[vtu_path] if vtu_path else [],
|
|
621
|
+
f"⚠️ No thermal results .vtu found yet under {run_path}\n"
|
|
622
|
+
)
|
|
623
|
+
|
|
572
624
|
def create_model(self):
|
|
573
625
|
# Request all tabs to save values again,
|
|
574
626
|
# which can do some update to saved_values
|
|
@@ -606,6 +658,7 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
606
658
|
|
|
607
659
|
# Run Python interpreter on that file
|
|
608
660
|
python_exe = sys.executable # Use the same Python interpreter
|
|
661
|
+
self._process_purpose = "create_mesh"
|
|
609
662
|
self.process.start(python_exe, [pymodel_filename])
|
|
610
663
|
|
|
611
664
|
|
|
@@ -622,6 +675,7 @@ class CreateModelTab(CreateModelTabBase):
|
|
|
622
675
|
|
|
623
676
|
# clear log
|
|
624
677
|
self.log_area.clear()
|
|
678
|
+
self._process_purpose = "run_simulation"
|
|
625
679
|
|
|
626
680
|
# try to start from output directory
|
|
627
681
|
run_path = saved_values['sim_path'] + "/elmer_model/" + saved_values['model_basename'] + "_data"
|
|
@@ -737,7 +791,7 @@ class ModelEditorTab(QWidget):
|
|
|
737
791
|
|
|
738
792
|
if forExport:
|
|
739
793
|
# these commands are only used within this GUI application to control gmsh
|
|
740
|
-
ignore_list.
|
|
794
|
+
ignore_list.extend(['preview_only','no_preview'])
|
|
741
795
|
|
|
742
796
|
for key in saved_values.keys():
|
|
743
797
|
if not key in special_keylist:
|
|
@@ -32,7 +32,7 @@ mesh fields, the Python model code generator bodies) is intentionally left
|
|
|
32
32
|
in setupEM.py / setupThermal.py, not here.
|
|
33
33
|
"""
|
|
34
34
|
|
|
35
|
-
import sys, os, json, pathlib, ast, webbrowser, io, contextlib
|
|
35
|
+
import sys, os, json, pathlib, ast, webbrowser, io, contextlib, subprocess, shutil, glob
|
|
36
36
|
import xml.etree.ElementTree as ET
|
|
37
37
|
import numpy as np
|
|
38
38
|
import requests
|
|
@@ -324,7 +324,7 @@ class FileInputTab(QWidget):
|
|
|
324
324
|
self.viamerge_layout = QHBoxLayout()
|
|
325
325
|
self.viamerge_label1 = QLabel("Merge via arrays with spacing ")
|
|
326
326
|
self.viamerge_label1.setFixedWidth(left_label_width)
|
|
327
|
-
self.viamerge_edit = QLineEdit("0")
|
|
327
|
+
self.viamerge_edit = QLineEdit("0.5")
|
|
328
328
|
self.viamerge_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
|
|
329
329
|
self.viamerge_edit.setFixedWidth(70)
|
|
330
330
|
self.viamerge_label2 = QLabel(" micron or more, value 0 disables via array merging")
|
|
@@ -643,7 +643,7 @@ class FileInputTab(QWidget):
|
|
|
643
643
|
merge_polygon_size = float(self.viamerge_edit.text())
|
|
644
644
|
except Exception:
|
|
645
645
|
QMessageBox.warning(self, "Error", f"Not a valid value for via array merging")
|
|
646
|
-
self.viamerge_edit.setText("0")
|
|
646
|
+
self.viamerge_edit.setText("0.5")
|
|
647
647
|
return False
|
|
648
648
|
saved_values["merge_polygon_size"] = float(merge_polygon_size)
|
|
649
649
|
|
|
@@ -1277,6 +1277,7 @@ class CreateModelTabBase(QWidget):
|
|
|
1277
1277
|
self.process.readyReadStandardOutput.connect(self.on_stdout)
|
|
1278
1278
|
self.process.readyReadStandardError.connect(self.on_stderr)
|
|
1279
1279
|
self.process.finished.connect(self.on_finished)
|
|
1280
|
+
self.process.errorOccurred.connect(self.on_process_error)
|
|
1280
1281
|
|
|
1281
1282
|
def on_modelname_edit_done(self):
|
|
1282
1283
|
# Model name edit field has changed
|
|
@@ -1298,6 +1299,64 @@ class CreateModelTabBase(QWidget):
|
|
|
1298
1299
|
"""Handle process completion."""
|
|
1299
1300
|
self.log_area.appendPlainText(f"\n--- Process finished with exit code {exit_code} ---\n")
|
|
1300
1301
|
|
|
1302
|
+
def on_process_error(self, error):
|
|
1303
|
+
"""Handle QProcess itself failing to launch or run - most importantly
|
|
1304
|
+
FailedToStart (program not found, or not executable, e.g. a .bat with content
|
|
1305
|
+
the current shell can't run). Without this, such failures were silent: none of
|
|
1306
|
+
readyReadStandardOutput/readyReadStandardError/finished ever fire for a process
|
|
1307
|
+
that never actually started, leaving the log blank with no indication anything
|
|
1308
|
+
went wrong.
|
|
1309
|
+
"""
|
|
1310
|
+
messages = {
|
|
1311
|
+
QProcess.FailedToStart: "the program could not be started (not found, or not executable)",
|
|
1312
|
+
QProcess.Crashed: "the process crashed",
|
|
1313
|
+
QProcess.Timedout: "the process timed out",
|
|
1314
|
+
QProcess.WriteError: "an error occurred while writing to the process",
|
|
1315
|
+
QProcess.ReadError: "an error occurred while reading from the process",
|
|
1316
|
+
QProcess.UnknownError: "an unknown error occurred",
|
|
1317
|
+
}
|
|
1318
|
+
detail = messages.get(error, f"error code {error}")
|
|
1319
|
+
self.log_area.appendPlainText(f"\n⚠️ Process error: {detail}\n")
|
|
1320
|
+
|
|
1321
|
+
def _open_in_paraview(self, file_paths, not_found_message):
|
|
1322
|
+
"""Locate ParaView and open it on file_paths (a list of .pvd/.vtu paths), or
|
|
1323
|
+
log not_found_message if file_paths is empty. Shared by setupEM.py (Palace
|
|
1324
|
+
field dumps / Elmer EM fields) and setupThermal.py (thermal_results.vtu) -
|
|
1325
|
+
each caller is responsible for finding its own result files and wording its
|
|
1326
|
+
own "nothing found" message; this only handles locating/launching ParaView.
|
|
1327
|
+
Detached, non-blocking launch (like klayout_setupEM.py's setupEM launch) -
|
|
1328
|
+
this is a fire-and-forget external GUI viewer, unrelated to self.process's
|
|
1329
|
+
solver-run lifecycle.
|
|
1330
|
+
"""
|
|
1331
|
+
if not file_paths:
|
|
1332
|
+
self.log_area.appendPlainText(not_found_message)
|
|
1333
|
+
return
|
|
1334
|
+
|
|
1335
|
+
paraview_exe = shutil.which("paraview")
|
|
1336
|
+
if paraview_exe is None and os.name == "nt":
|
|
1337
|
+
# Not on PATH: fall back to searching the usual install locations, newest first.
|
|
1338
|
+
candidates = (
|
|
1339
|
+
glob.glob(r"C:\Program Files\ParaView*\bin\paraview.exe")
|
|
1340
|
+
+ glob.glob(r"C:\Program Files (x86)\ParaView*\bin\paraview.exe")
|
|
1341
|
+
)
|
|
1342
|
+
if candidates:
|
|
1343
|
+
paraview_exe = max(candidates, key=os.path.getmtime)
|
|
1344
|
+
|
|
1345
|
+
if paraview_exe is None:
|
|
1346
|
+
self.log_area.appendPlainText(
|
|
1347
|
+
"⚠️ ParaView not found on PATH. Install it, or add it to PATH, "
|
|
1348
|
+
"then open manually:\n" + "\n".join(file_paths) + "\n"
|
|
1349
|
+
)
|
|
1350
|
+
return
|
|
1351
|
+
|
|
1352
|
+
env = os.environ.copy()
|
|
1353
|
+
env.pop("PYTHONHOME", None)
|
|
1354
|
+
try:
|
|
1355
|
+
subprocess.Popen([paraview_exe, *file_paths], env=env)
|
|
1356
|
+
self.log_area.appendPlainText("Starting ParaView on:\n" + "\n".join(file_paths) + "\n")
|
|
1357
|
+
except OSError as e:
|
|
1358
|
+
self.log_area.appendPlainText(f"⚠️ Failed to launch ParaView: {e}\n")
|
|
1359
|
+
|
|
1301
1360
|
def browse_directory(self):
|
|
1302
1361
|
directory = QFileDialog.getExistingDirectory(
|
|
1303
1362
|
self,
|
|
@@ -1328,9 +1387,15 @@ class CreateModelTabBase(QWidget):
|
|
|
1328
1387
|
if "===" in model_basename:
|
|
1329
1388
|
model_basename = ""
|
|
1330
1389
|
|
|
1331
|
-
#
|
|
1332
|
-
|
|
1333
|
-
|
|
1390
|
+
# Strip a stale simulator-name prefix left over from a different solver choice
|
|
1391
|
+
# (e.g. "elmer_..." after switching to Palace mode) - but never strip a prefix
|
|
1392
|
+
# that already matches the current choice, otherwise importing an existing
|
|
1393
|
+
# model and reusing its filename would silently rename it to a different file
|
|
1394
|
+
# (setupThermal has no PalaceMode attribute at all; it's always Elmer, so only
|
|
1395
|
+
# a "palace_" prefix would ever be considered stale there).
|
|
1396
|
+
palace_mode = getattr(self.MainWindow, 'PalaceMode', False)
|
|
1397
|
+
mismatched_prefix = 'elmer_' if palace_mode else 'palace_'
|
|
1398
|
+
model_basename = model_basename.replace(mismatched_prefix, '')
|
|
1334
1399
|
|
|
1335
1400
|
self.modelname_edit.setText(model_basename)
|
|
1336
1401
|
|
|
@@ -1669,6 +1734,8 @@ class MainWindowBase(QMainWindow):
|
|
|
1669
1734
|
"XML_filename": "SubstrateFile",
|
|
1670
1735
|
"GdsFile": "GdsFile",
|
|
1671
1736
|
"purpose": "purpose",
|
|
1737
|
+
"cellname": "cellname",
|
|
1738
|
+
"variable_overrides": "variable_overrides",
|
|
1672
1739
|
"SubstrateFile": "SubstrateFile",
|
|
1673
1740
|
"merge_polygon_size": "merge_polygon_size",
|
|
1674
1741
|
"preprocess_gds": "preprocess_gds",
|
|
@@ -1681,12 +1748,12 @@ class MainWindowBase(QMainWindow):
|
|
|
1681
1748
|
"fpoint": "fpoint",
|
|
1682
1749
|
"fdump": "fdump",
|
|
1683
1750
|
"refined_cellsize": "refined_cellsize",
|
|
1751
|
+
"refined_cellsize_override": "refined_cellsize_override",
|
|
1684
1752
|
"cells_per_wavelength": "cells_per_wavelength",
|
|
1685
1753
|
"meshsize_max": "meshsize_max",
|
|
1686
1754
|
"adaptive_mesh_iterations": "adaptive_mesh_iterations",
|
|
1687
1755
|
"order": "order",
|
|
1688
1756
|
"iterative": "iterative",
|
|
1689
|
-
"elmer": "elmer",
|
|
1690
1757
|
"ELMER_MPI_THREADS": "ELMER_MPI_THREADS"
|
|
1691
1758
|
}
|
|
1692
1759
|
|
|
@@ -1706,7 +1773,11 @@ class MainWindowBase(QMainWindow):
|
|
|
1706
1773
|
if import_key not in import_value: # skip the section where key might appear in different context
|
|
1707
1774
|
# get the internal name for this variable
|
|
1708
1775
|
varname = import_mapping.get(import_key, '')
|
|
1709
|
-
if varname
|
|
1776
|
+
if varname in ("fpoint", "fdump"):
|
|
1777
|
+
# same Hz-in-code / GHz-in-GUI unit split as fstart/fstop/fstep below,
|
|
1778
|
+
# just per-element since these are lists
|
|
1779
|
+
saved_values[varname] = [f / 1e9 for f in ast.literal_eval(import_value)]
|
|
1780
|
+
elif varname in ("variable_overrides", "refined_cellsize_override"):
|
|
1710
1781
|
saved_values[varname] = ast.literal_eval(import_value)
|
|
1711
1782
|
elif varname in ["gds_filename", "XML_filename", "GdsFile", "SubstrateFile"]:
|
|
1712
1783
|
# check if we have full path for files in imported Python script,
|
|
@@ -1727,6 +1798,19 @@ class MainWindowBase(QMainWindow):
|
|
|
1727
1798
|
else:
|
|
1728
1799
|
saved_values[varname] = raw
|
|
1729
1800
|
|
|
1801
|
+
# ask whether future "Create Model" output should overwrite this same
|
|
1802
|
+
# file, or start a fresh model (today's GDS-derived default)
|
|
1803
|
+
reuse = QMessageBox.question(
|
|
1804
|
+
self, "Import Model",
|
|
1805
|
+
f"Use '{os.path.basename(file_path)}' as the output file for this model too?\n\n"
|
|
1806
|
+
"Yes: Create Model / Start Simulation will overwrite this file.\n"
|
|
1807
|
+
"No: pick a model name and target directory on the Create Model(s) tab.",
|
|
1808
|
+
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
|
|
1809
|
+
) == QMessageBox.Yes
|
|
1810
|
+
if reuse:
|
|
1811
|
+
saved_values['sim_path'] = os.path.dirname(file_path).replace('\\', '/')
|
|
1812
|
+
saved_values['model_basename'] = pathlib.Path(file_path).stem
|
|
1813
|
+
|
|
1730
1814
|
# read port/thermal assignments in workflow syntax for gds2palace Python code, and
|
|
1731
1815
|
# apply any app-specific post-import state (e.g. setupEM's simulator mode)
|
|
1732
1816
|
self.apply_python_import_data(file_path)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
########################################################################
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2025-2026 Volker Muehlhaus and IHP PDK Authors
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the GNU General Public License, Version 3.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# https://www.gnu.org/licenses/gpl-3.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
#
|
|
17
|
+
########################################################################
|
|
18
|
+
|
|
19
|
+
"""Parse Elmer thermal solver output (thermal_results.dat / thermal_results.vtu)
|
|
20
|
+
into a human-readable summary for the setupThermal "Create Model" log. No Qt
|
|
21
|
+
dependency, so this can be exercised standalone against real Elmer output
|
|
22
|
+
directories.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import glob
|
|
27
|
+
|
|
28
|
+
import meshio
|
|
29
|
+
|
|
30
|
+
# Elmer's Post File in util_elmer.py is written with "Coordinate Scaling = Real 1e-6"
|
|
31
|
+
# applied on read-in (mesh coordinates in um are scaled down to meters for the solve),
|
|
32
|
+
# so node coordinates recovered from the .vtu are in meters and need to be scaled back
|
|
33
|
+
# up to um to match the layout's native units.
|
|
34
|
+
_METERS_TO_UM = 1e6
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _candidate_dirs(run_path):
|
|
38
|
+
# util_elmer.py writes "Filename = ../thermal_results.dat" and
|
|
39
|
+
# "Post File = ../thermal_results.vtu", which reads as "one level above run_path"
|
|
40
|
+
# -- but Elmer actually resolves both relative to the Mesh DB directory ("mesh"
|
|
41
|
+
# under run_path, per "Mesh DB \"mesh\" \".\"" in the .sif), so "../" cancels back
|
|
42
|
+
# out to run_path itself (confirmed via thermal_results.dat.names: "Metadata for
|
|
43
|
+
# SaveScalars file: mesh/../thermal_results.dat"). run_path is therefore the real
|
|
44
|
+
# location; its parent is kept as a defensive fallback for other Elmer builds/versions.
|
|
45
|
+
run_path = os.path.normpath(run_path)
|
|
46
|
+
return [run_path, os.path.dirname(run_path)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_thermal_dat(run_path):
|
|
50
|
+
"""Return the path to thermal_results.dat, or None if it doesn't exist yet."""
|
|
51
|
+
for d in _candidate_dirs(run_path):
|
|
52
|
+
path = os.path.join(d, "thermal_results.dat")
|
|
53
|
+
if os.path.isfile(path):
|
|
54
|
+
return path
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_thermal_vtu(run_path):
|
|
59
|
+
"""Return the path to the thermal results .vtu file, or None if it doesn't exist yet.
|
|
60
|
+
|
|
61
|
+
Elmer's Post File writer appends a timestep suffix (e.g. thermal_results_t0001.vtu)
|
|
62
|
+
even for a single steady-state solve, so this globs rather than matching a fixed name;
|
|
63
|
+
if more than one is found (re-run, or a multi-iteration solve), the most recently
|
|
64
|
+
written one wins.
|
|
65
|
+
"""
|
|
66
|
+
matches = []
|
|
67
|
+
for d in _candidate_dirs(run_path):
|
|
68
|
+
matches += glob.glob(os.path.join(d, "thermal_results*.vtu"))
|
|
69
|
+
if not matches:
|
|
70
|
+
return None
|
|
71
|
+
return max(matches, key=os.path.getmtime)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_minmax_temperature(run_path):
|
|
75
|
+
"""Return (t_min, t_max) from thermal_results.dat, or None if missing/unparsable."""
|
|
76
|
+
path = find_thermal_dat(run_path)
|
|
77
|
+
if path is None:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
81
|
+
lines = [line.strip() for line in f if line.strip()]
|
|
82
|
+
if not lines:
|
|
83
|
+
return None
|
|
84
|
+
# SaveScalars writes one row per solver call; steady-state thermal is a single
|
|
85
|
+
# solve, but take the last line defensively in case Elmer ever appends more.
|
|
86
|
+
values = [float(v) for v in lines[-1].split()]
|
|
87
|
+
if len(values) < 2:
|
|
88
|
+
return None
|
|
89
|
+
# Column order is fixed by our own .sif generation: Operator 1 = min, Operator 2 = max.
|
|
90
|
+
return values[0], values[1]
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find_max_temperature_location(run_path):
|
|
96
|
+
"""Return (x, y, z) in um of the mesh node with the highest temperature, or None
|
|
97
|
+
if the thermal results .vtu doesn't exist yet or can't be read/parsed.
|
|
98
|
+
"""
|
|
99
|
+
path = find_thermal_vtu(run_path)
|
|
100
|
+
if path is None:
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
mesh = meshio.read(path)
|
|
104
|
+
temp_key = next(
|
|
105
|
+
(key for key in mesh.point_data if "temp" in key.lower()),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
if temp_key is None:
|
|
109
|
+
return None
|
|
110
|
+
temperature = mesh.point_data[temp_key]
|
|
111
|
+
max_index = temperature.argmax()
|
|
112
|
+
x, y, z = mesh.points[max_index]
|
|
113
|
+
return x * _METERS_TO_UM, y * _METERS_TO_UM, z * _METERS_TO_UM
|
|
114
|
+
except Exception:
|
|
115
|
+
# Any read/parse failure (unsupported VTU variant, half-written file while a
|
|
116
|
+
# simulation is still running, etc.) should never block the min/max summary.
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_SOURCE_TABLE_HEADERS = ["Type", "GDS Layer", "Target Layer", "Value"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def format_source_table(rows):
|
|
124
|
+
"""Format (type, gds_layer, target_layer, value) string rows into an aligned table,
|
|
125
|
+
same layout style as palace_results.py's AMR results table. The caller decides row
|
|
126
|
+
order (heat sources first, then constant-temperature boundaries, per setupThermal.py).
|
|
127
|
+
"""
|
|
128
|
+
headers = _SOURCE_TABLE_HEADERS
|
|
129
|
+
widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(len(headers))]
|
|
130
|
+
|
|
131
|
+
def fmt_row(cells):
|
|
132
|
+
return " | ".join(
|
|
133
|
+
cell.rjust(widths[i]) if i == len(cells) - 1 else cell.ljust(widths[i])
|
|
134
|
+
for i, cell in enumerate(cells)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
lines = [fmt_row(headers), "-+-".join("-" * w for w in widths)]
|
|
138
|
+
lines += [fmt_row(row) for row in rows]
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def build_thermal_summary(run_path, source_lines=None):
|
|
143
|
+
"""Return a formatted multi-line summary of Elmer thermal results found relative
|
|
144
|
+
to run_path (the case.sif working directory), or an explanatory message if
|
|
145
|
+
nothing is there yet.
|
|
146
|
+
|
|
147
|
+
source_lines, if given, is a list of pre-formatted strings describing the model's
|
|
148
|
+
heat sources / constant-temperature boundaries (e.g. str(heatsource_instance)) --
|
|
149
|
+
this module only knows about Elmer's output files, not the GUI's in-memory model,
|
|
150
|
+
so the caller supplies these rather than this module importing simulation_setup.
|
|
151
|
+
"""
|
|
152
|
+
minmax = read_minmax_temperature(run_path)
|
|
153
|
+
if minmax is None:
|
|
154
|
+
return (f"No thermal results found yet (expected thermal_results.dat under {run_path}) "
|
|
155
|
+
"-- has the simulation finished?")
|
|
156
|
+
t_min, t_max = minmax
|
|
157
|
+
|
|
158
|
+
location = find_max_temperature_location(run_path)
|
|
159
|
+
if location is not None:
|
|
160
|
+
x, y, z = location
|
|
161
|
+
location_line = f"Hotspot location : x={x:.3f} um y={y:.3f} um z={z:.3f} um"
|
|
162
|
+
else:
|
|
163
|
+
location_line = "Hotspot location : n/a (see thermal_results.vtu)"
|
|
164
|
+
|
|
165
|
+
lines = ["=== Thermal results ==="]
|
|
166
|
+
if source_lines:
|
|
167
|
+
lines.extend(source_lines)
|
|
168
|
+
lines.append("-" * 40)
|
|
169
|
+
lines += [
|
|
170
|
+
f"Min temperature : {t_min:.2f} K",
|
|
171
|
+
f"Max temperature : {t_max:.2f} K",
|
|
172
|
+
location_line,
|
|
173
|
+
"=" * 40,
|
|
174
|
+
]
|
|
175
|
+
return "\n".join(lines)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: setupEM
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Python tool for configuration of gds2palace workflow with GUI.
|
|
5
5
|
Author-email: Volker Muehlhaus <volker@muehlhaus.com>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -15,6 +15,7 @@ Requires-Dist: scikit-rf
|
|
|
15
15
|
Requires-Dist: matplotlib
|
|
16
16
|
Requires-Dist: numpy
|
|
17
17
|
Requires-Dist: gdspy
|
|
18
|
+
Requires-Dist: meshio
|
|
18
19
|
Dynamic: license-file
|
|
19
20
|
|
|
20
21
|
# Python GUI for gds2palace
|
|
@@ -12,6 +12,7 @@ src/setupEM/setupThermal.py
|
|
|
12
12
|
src/setupEM/setup_common.py
|
|
13
13
|
src/setupEM/stackupEditor.py
|
|
14
14
|
src/setupEM/stackup_writer.py
|
|
15
|
+
src/setupEM/thermal_results.py
|
|
15
16
|
src/setupEM.egg-info/PKG-INFO
|
|
16
17
|
src/setupEM.egg-info/SOURCES.txt
|
|
17
18
|
src/setupEM.egg-info/dependency_links.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|