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.
Files changed (27) hide show
  1. {setupem-0.5.1/src/setupEM.egg-info → setupem-0.6.1}/PKG-INFO +2 -1
  2. {setupem-0.5.1 → setupem-0.6.1}/pyproject.toml +2 -1
  3. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/__init__.py +1 -1
  4. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/palace_results.py +18 -0
  5. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/result_viewer.py +30 -5
  6. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setupEM.py +302 -59
  7. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setupThermal.py +58 -4
  8. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/setup_common.py +92 -8
  9. setupem-0.6.1/src/setupEM/thermal_results.py +175 -0
  10. {setupem-0.5.1 → setupem-0.6.1/src/setupEM.egg-info}/PKG-INFO +2 -1
  11. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/SOURCES.txt +1 -0
  12. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/requires.txt +1 -0
  13. {setupem-0.5.1 → setupem-0.6.1}/LICENSE +0 -0
  14. {setupem-0.5.1 → setupem-0.6.1}/README.md +0 -0
  15. {setupem-0.5.1 → setupem-0.6.1}/README_pypi.md +0 -0
  16. {setupem-0.5.1 → setupem-0.6.1}/setup.cfg +0 -0
  17. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/__main__.py +0 -0
  18. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_100um.xml +0 -0
  19. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um.xml +0 -0
  20. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  21. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  22. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/momentum_import.py +0 -0
  23. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/stackupEditor.py +0 -0
  24. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM/stackup_writer.py +0 -0
  25. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/dependency_links.txt +0 -0
  26. {setupem-0.5.1 → setupem-0.6.1}/src/setupEM.egg-info/entry_points.txt +0 -0
  27. {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.5.1
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
@@ -19,7 +19,8 @@ dependencies = [
19
19
  "scikit-rf",
20
20
  "matplotlib",
21
21
  "numpy",
22
- "gdspy"
22
+ "gdspy",
23
+ "meshio"
23
24
  ]
24
25
  requires-python = ">=3.9"
25
26
 
@@ -24,4 +24,4 @@ A Python tool for EM setup using gds2palace.
24
24
  from .setupEM import main
25
25
 
26
26
  __all__ = ["main"]
27
- __version__ = "0.5.1"
27
+ __version__ = "0.6.1"
@@ -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 newest
368
- # file if nothing is checked (e.g. first open), so the window
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
- newest = max(self._master_files, key=os.path.getmtime)
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.label3 = QLabel(" µm ")
673
- self.refinement_layout.addWidget(self.label3)
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
- if "," in str(air):
1084
- # we have a list of 6 values
1085
- air_as_list = air.split(',')
1086
- if len(air_as_list) == 6:
1087
- self.airaround_box.setCurrentIndex(1)
1088
- self.airxmin_edit.setText(air_as_list[0])
1089
- self.airxmax_edit.setText(air_as_list[1])
1090
- self.airymin_edit.setText(air_as_list[2])
1091
- self.airymax_edit.setText(air_as_list[3])
1092
- self.airzmin_edit.setText(air_as_list[4])
1093
- self.airzmax_edit.setText(air_as_list[5])
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 Results...")
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
- if len(raw_files) > 1:
1193
- self.log_area.appendPlainText("⚠️ Multiple raw S-parameter result files found, using the first one:")
1194
- for path in raw_files:
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 = raw_files[0]
1367
+ raw_file = max(final_candidates, key=os.path.getmtime)
1197
1368
 
1198
- # snp2le's GUI mode takes no filename argument (only its "-b" batch mode does) -
1199
- # the file has to be loaded by hand via the GUI's own file picker, so point the
1200
- # user at it explicitly and start snp2le in that file's directory, so its file
1201
- # picker opens there instead of wherever setupEM happened to be launched from.
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 has no such subdirectory - its solver
1279
- writes scalar_results.*/fields*.vtu*/*.sNp files directly into run_path
1280
- alongside the mesh/config - so those are matched and removed individually.
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
- if os.path.isdir(run_path):
1290
- for fn in os.listdir(run_path):
1291
- full_path = os.path.join(run_path, fn)
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.dat", "scalar_results.names") or \
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
- # rename file to batch file, but only if that has not already
1394
- # been done by a previous run (otherwise os.rename() fails on
1395
- # the second run because run_elmer no longer exists)
1396
- run_file_orig = os.path.join(run_path, 'run_elmer')
1397
- run_file = run_file_orig.replace('run_elmer','run_elmer.bat')
1398
- if os.path.exists(run_file_orig):
1399
- os.rename(run_file_orig, run_file)
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
- self.process.start("run_elmer.bat")
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.append(['preview_only','no_preview'])
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
- self.frequencies_tab.dump_group.setVisible(False)
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
- # set simulator
1812
- if self.saved_values.get("elmer", False):
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("10")
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("200")
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("200")
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.append(['preview_only','no_preview'])
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
- # no simulator name prefix
1332
- model_basename = model_basename.replace('palace_', '')
1333
- model_basename = model_basename.replace('elmer_', '')
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 == "fpoint" or varname == "fdump":
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.5.1
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
@@ -6,3 +6,4 @@ scikit-rf
6
6
  matplotlib
7
7
  numpy
8
8
  gdspy
9
+ meshio
File without changes
File without changes
File without changes
File without changes
File without changes