setupEM 0.5.0__tar.gz → 0.6.0__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 (26) hide show
  1. {setupem-0.5.0/src/setupEM.egg-info → setupem-0.6.0}/PKG-INFO +1 -1
  2. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/__init__.py +1 -1
  3. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/palace_results.py +136 -10
  4. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/result_viewer.py +30 -5
  5. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/setupEM.py +203 -37
  6. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/setupThermal.py +4 -4
  7. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/setup_common.py +55 -4
  8. {setupem-0.5.0 → setupem-0.6.0/src/setupEM.egg-info}/PKG-INFO +1 -1
  9. {setupem-0.5.0 → setupem-0.6.0}/LICENSE +0 -0
  10. {setupem-0.5.0 → setupem-0.6.0}/README.md +0 -0
  11. {setupem-0.5.0 → setupem-0.6.0}/README_pypi.md +0 -0
  12. {setupem-0.5.0 → setupem-0.6.0}/pyproject.toml +0 -0
  13. {setupem-0.5.0 → setupem-0.6.0}/setup.cfg +0 -0
  14. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/__main__.py +0 -0
  15. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/data/SG13G2_100um.xml +0 -0
  16. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/data/SG13G2_200um.xml +0 -0
  17. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  18. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  19. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/momentum_import.py +0 -0
  20. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/stackupEditor.py +0 -0
  21. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM/stackup_writer.py +0 -0
  22. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM.egg-info/SOURCES.txt +0 -0
  23. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM.egg-info/dependency_links.txt +0 -0
  24. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM.egg-info/entry_points.txt +0 -0
  25. {setupem-0.5.0 → setupem-0.6.0}/src/setupEM.egg-info/requires.txt +0 -0
  26. {setupem-0.5.0 → setupem-0.6.0}/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.0
3
+ Version: 0.6.0
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
@@ -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.0"
27
+ __version__ = "0.6.0"
@@ -25,6 +25,8 @@ import os
25
25
  import json
26
26
  import csv
27
27
  import re
28
+ import math
29
+ import cmath
28
30
 
29
31
  _ITERATION_RE = re.compile(r'^iteration(\d+)$')
30
32
 
@@ -79,14 +81,121 @@ def _read_error_indicators(dir_path):
79
81
  try:
80
82
  return {
81
83
  'norm': float(fields['Norm']),
82
- 'minimum': float(fields['Minimum']),
83
84
  'maximum': float(fields['Maximum']),
84
- 'mean': float(fields['Mean']),
85
85
  }
86
86
  except (KeyError, ValueError):
87
87
  return None
88
88
 
89
89
 
90
+ def _parse_palace_port_s_csv(path):
91
+ """Parse a Palace port-S.csv file into (freq_list, S_dB_list, S_arg_list,
92
+ num_ports), where S_dB_list/S_arg_list are one dict-per-frequency mapping
93
+ 'i j' port-pair strings to their |S| (dB) / phase (deg) string values.
94
+ """
95
+ freq = []
96
+ S_dB = []
97
+ S_arg = []
98
+ params = []
99
+ num_ports = 0
100
+
101
+ with open(path, 'r', encoding='utf-8') as f:
102
+ for line in f:
103
+ aline = line.rstrip()
104
+ aline = aline.replace(",", "")
105
+ aline = aline.replace("(dB)", "")
106
+ aline = aline.replace("(deg.)", "")
107
+
108
+ if 'Hz)' in aline:
109
+ # header line: items are like |S[1][1]| arg(S[1][1])
110
+ items = aline.split()
111
+ for item in items:
112
+ if '|' in item:
113
+ Sxx = item.replace('|S', '').replace('|', '')
114
+ Sxx = Sxx.replace('][', ' ').replace('[', '').replace(']', '')
115
+ params.append(Sxx)
116
+ a, b = (int(x) for x in Sxx.split())
117
+ num_ports = max(num_ports, a, b)
118
+ continue
119
+
120
+ items = aline.split()
121
+ if not items:
122
+ continue
123
+ dB = {}
124
+ arg = {}
125
+ for param in params:
126
+ dB_index = 2 * params.index(param) + 1
127
+ arg_index = dB_index + 1
128
+ dB[param] = items[dB_index]
129
+ arg[param] = items[arg_index]
130
+ freq.append(items[0])
131
+ S_dB.append(dB)
132
+ S_arg.append(arg)
133
+
134
+ return freq, S_dB, S_arg, num_ports
135
+
136
+
137
+ def _read_port_s_data(dir_path):
138
+ """Parse dir_path/port-S.csv (if present), or None if the file is
139
+ missing, unfinished, or malformed (e.g. a still-running pass).
140
+ """
141
+ path = os.path.join(dir_path, 'port-S.csv')
142
+ if not os.path.isfile(path):
143
+ return None
144
+ try:
145
+ freq, S_dB, S_arg, num_ports = _parse_palace_port_s_csv(path)
146
+ except (OSError, ValueError, IndexError):
147
+ return None
148
+ if not freq:
149
+ return None
150
+ return freq, S_dB, S_arg, num_ports
151
+
152
+
153
+ def _max_delta_s(prev_data, curr_data):
154
+ """Max |S_curr - S_prev| across all ports and frequencies common to both
155
+ passes, mirroring HFSS's per-pass Max Delta S. Returns None if either
156
+ pass has no port-S data, or the two use different port counts.
157
+ """
158
+ if prev_data is None or curr_data is None:
159
+ return None
160
+ freq_p, dB_p, arg_p, ports_p = prev_data
161
+ freq_c, dB_c, arg_c, ports_c = curr_data
162
+ if ports_p != ports_c:
163
+ return None
164
+
165
+ max_delta = 0.0
166
+ found_any = False
167
+ for idx in range(min(len(freq_p), len(freq_c))):
168
+ for key in set(dB_p[idx]) & set(dB_c[idx]):
169
+ try:
170
+ Sp = cmath.rect(10 ** (float(dB_p[idx][key]) / 20.0), math.radians(float(arg_p[idx][key])))
171
+ Sc = cmath.rect(10 ** (float(dB_c[idx][key]) / 20.0), math.radians(float(arg_c[idx][key])))
172
+ except ValueError:
173
+ continue
174
+ found_any = True
175
+ max_delta = max(max_delta, abs(Sc - Sp))
176
+ return max_delta if found_any else None
177
+
178
+
179
+ def _collect_amr_rows(output_dir, iteration_dirs):
180
+ """One row per AMR iteration subfolder, plus the root output_dir as
181
+ 'Final': (label, palace_json_dict_or_None, error_indicators_dict_or_None,
182
+ max_delta_s_vs_previous_row_or_None).
183
+ """
184
+ dirs_in_order = list(iteration_dirs) + [output_dir]
185
+ labels = [os.path.basename(d) for d in iteration_dirs] + ["Final"]
186
+
187
+ rows = []
188
+ prev_port_s = None
189
+ for label, d in zip(labels, dirs_in_order):
190
+ summary = _read_palace_json(d)
191
+ errors = _read_error_indicators(d)
192
+ port_s = _read_port_s_data(d)
193
+ delta_s = _max_delta_s(prev_port_s, port_s)
194
+ rows.append((label, summary, errors, delta_s))
195
+ prev_port_s = port_s
196
+ return rows
197
+
198
+
90
199
  def _list_iteration_dirs(output_dir):
91
200
  if not os.path.isdir(output_dir):
92
201
  return []
@@ -128,6 +237,22 @@ def _format_sci(x):
128
237
  return 'n/a' if x is None else f"{x:.3e}"
129
238
 
130
239
 
240
+ def _format_delta(x):
241
+ return 'n/a' if x is None else f"{x:.4f}"
242
+
243
+
244
+ def _save_convergence_summary(run_path, summary_text):
245
+ """Save the AMR summary table alongside the run's config.json /
246
+ port_information.json, so it's available on disk without re-running the
247
+ simulation or digging through the setupEM log.
248
+ """
249
+ try:
250
+ with open(os.path.join(run_path, "mesh_convergence_summary.txt"), "w", encoding="utf-8") as f:
251
+ f.write(summary_text + "\n")
252
+ except OSError:
253
+ pass
254
+
255
+
131
256
  def build_results_summary(run_path, model_basename):
132
257
  """Return a formatted multi-line summary of Palace results found under run_path,
133
258
  or an explanatory message if nothing is there yet.
@@ -155,19 +280,18 @@ def build_results_summary(run_path, model_basename):
155
280
  if errors:
156
281
  lines.append(
157
282
  f"Error indicator : Norm={_format_sci(errors['norm'])} "
158
- f"Max={_format_sci(errors['maximum'])} Mean={_format_sci(errors['mean'])}"
283
+ f"Max={_format_sci(errors['maximum'])}"
159
284
  )
160
285
  lines.append("=" * 40)
161
286
  return "\n".join(lines)
162
287
 
163
288
  # Adaptive mesh refinement: one row per iteration subfolder, plus the root as "Final"
164
- rows = [(os.path.basename(it_dir), _read_palace_json(it_dir), _read_error_indicators(it_dir))
165
- for it_dir in iteration_dirs]
166
- rows.append(("Final", _read_palace_json(output_dir), _read_error_indicators(output_dir)))
289
+ rows = _collect_amr_rows(output_dir, iteration_dirs)
167
290
 
168
- headers = ["Iteration", "DOF", "Mesh elems", "Error Norm", "Error Max", "Error Mean", "Time", "Peak RAM"]
291
+ headers = ["Iteration", "DOF", "Mesh elems", "Error Norm", "Error Max",
292
+ "Max dS", "Time", "Peak RAM"]
169
293
  table_rows = []
170
- for label, summary, errors in rows:
294
+ for label, summary, errors, delta_s in rows:
171
295
  summary = summary or {}
172
296
  errors = errors or {}
173
297
  table_rows.append([
@@ -176,7 +300,7 @@ def build_results_summary(run_path, model_basename):
176
300
  _format_int(summary.get('mesh_elements')),
177
301
  _format_sci(errors.get('norm')),
178
302
  _format_sci(errors.get('maximum')),
179
- _format_sci(errors.get('mean')),
303
+ _format_delta(delta_s),
180
304
  _format_duration(summary.get('duration_s')),
181
305
  _format_ram(summary.get('peak_ram_mb')),
182
306
  ])
@@ -196,4 +320,6 @@ def build_results_summary(run_path, model_basename):
196
320
  for row in table_rows:
197
321
  lines.append(fmt_row(row))
198
322
  lines.append("=" * len(header_line))
199
- return "\n".join(lines)
323
+ summary_text = "\n".join(lines)
324
+ _save_convergence_summary(run_path, summary_text)
325
+ return summary_text
@@ -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
@@ -644,6 +644,130 @@ class PortsTab(QWidget):
644
644
 
645
645
 
646
646
 
647
+ class RefinedCellsizeOverrideDialog(QDialog):
648
+ """Popup editor for settings['refined_cellsize_override'], opened from
649
+ MeshTab's "Advanced..." button. Lets the user set a different mesh
650
+ refinement cell size for specific metal/sheet layers, instead of the
651
+ single global "Mesh refinement at metal edges" value. The Layer column
652
+ is restricted to metals_list.getallplanarmetals() (conductor/sheet
653
+ layers) - vias and dielectrics never produce boundary curves in
654
+ gds2palace's meshing code, so a name outside that list would silently
655
+ have no effect if it were allowed to be typed in.
656
+ """
657
+
658
+ def __init__(self, parent, layer_choices, current_overrides):
659
+ super().__init__(parent)
660
+ self.setAttribute(Qt.WA_DeleteOnClose)
661
+ self.setWindowTitle("Mesh Refinement Overrides")
662
+ self.resize(420, 400)
663
+ self.setModal(True)
664
+
665
+ self._layer_choices = layer_choices
666
+ self._result = []
667
+
668
+ layout = QVBoxLayout()
669
+
670
+ info = QLabel("Override the mesh refinement cell size for specific layers\n"
671
+ "(all other layers keep using the default set above).")
672
+ layout.addWidget(info)
673
+
674
+ self.table = QTableWidget()
675
+ self.table.setColumnCount(3)
676
+ self.table.setHorizontalHeaderLabels(["Layer", "Cell size (µm)", ""])
677
+ self.table.verticalHeader().setVisible(False)
678
+ self.table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
679
+ header = self.table.horizontalHeader()
680
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
681
+ header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
682
+ header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
683
+ layout.addWidget(self.table)
684
+
685
+ for name, value in current_overrides:
686
+ self._add_row(name, value)
687
+
688
+ add_row_layout = QHBoxLayout()
689
+ add_btn = QPushButton("+ Add Row")
690
+ add_btn.clicked.connect(lambda: self._add_row())
691
+ add_row_layout.addWidget(add_btn)
692
+ add_row_layout.addStretch()
693
+ layout.addLayout(add_row_layout)
694
+
695
+ button_layout = QHBoxLayout()
696
+ button_layout.addStretch()
697
+ ok_btn = QPushButton("OK")
698
+ ok_btn.clicked.connect(self._on_ok)
699
+ button_layout.addWidget(ok_btn)
700
+ cancel_btn = QPushButton("Cancel")
701
+ cancel_btn.clicked.connect(self.reject)
702
+ button_layout.addWidget(cancel_btn)
703
+ layout.addLayout(button_layout)
704
+
705
+ self.setLayout(layout)
706
+
707
+ def _add_row(self, name="", value=""):
708
+ row = self.table.rowCount()
709
+ self.table.insertRow(row)
710
+
711
+ combo = QComboBox()
712
+ combo.setStyleSheet(COMBO_STYLE_OPTIONAL)
713
+ combo.addItem("")
714
+ combo.addItems(self._layer_choices)
715
+ if name and name not in self._layer_choices:
716
+ # tolerate a saved override for a layer no longer in the current
717
+ # stackup, same tolerant style as the Cellname combo box
718
+ combo.addItem(name)
719
+ if name:
720
+ combo.setCurrentText(name)
721
+ self.table.setCellWidget(row, 0, combo)
722
+
723
+ value_text = "" if value == "" else str(value)
724
+ self.table.setItem(row, 1, QTableWidgetItem(value_text))
725
+
726
+ remove_btn = QPushButton("✕")
727
+ remove_btn.setFixedWidth(28)
728
+ remove_btn.clicked.connect(lambda: self._remove_row_containing(remove_btn))
729
+ self.table.setCellWidget(row, 2, remove_btn)
730
+
731
+ def _remove_row_containing(self, button):
732
+ # look up the row by identity rather than a captured index, since
733
+ # row indices shift whenever an earlier row is removed
734
+ for row in range(self.table.rowCount()):
735
+ if self.table.cellWidget(row, 2) is button:
736
+ self.table.removeRow(row)
737
+ return
738
+
739
+ def _on_ok(self):
740
+ overrides = []
741
+ seen_layers = set()
742
+ for row in range(self.table.rowCount()):
743
+ combo = self.table.cellWidget(row, 0)
744
+ layer = combo.currentText().strip() if combo else ""
745
+ if not layer:
746
+ continue # blank row - not yet configured, skip silently
747
+
748
+ value_item = self.table.item(row, 1)
749
+ value_text = value_item.text().strip() if value_item else ""
750
+ try:
751
+ value = float(value_text)
752
+ if value <= 0:
753
+ raise ValueError
754
+ except ValueError:
755
+ QMessageBox.warning(self, "Error", f"Not a valid cell size for layer '{layer}'")
756
+ return
757
+
758
+ if layer in seen_layers:
759
+ QMessageBox.warning(self, "Error", f"Layer '{layer}' is selected more than once")
760
+ return
761
+ seen_layers.add(layer)
762
+ overrides.append([layer, value])
763
+
764
+ self._result = overrides
765
+ self.accept()
766
+
767
+ def get_overrides(self):
768
+ return self._result
769
+
770
+
647
771
  class MeshTab(QWidget):
648
772
  def __init__(self, MainWindow):
649
773
  super().__init__()
@@ -662,42 +786,44 @@ class MeshTab(QWidget):
662
786
  self.mesh_layout = QVBoxLayout()
663
787
 
664
788
  self.refinement_layout = QHBoxLayout()
665
- self.label2 = QLabel("Mesh refinement at metal edges")
789
+ self.label2 = QLabel("Mesh refinement at metal edges (µm)")
666
790
  self.label2.setFixedWidth(label_width)
667
791
  self.refinement_layout.addWidget(self.label2)
668
792
  self.refinement_edit = QLineEdit("5")
669
793
  self.refinement_edit.setFixedWidth(edit_width)
670
794
  self.refinement_edit.setStyleSheet(EDIT_STYLE_REQUIRED)
671
795
  self.refinement_layout.addWidget(self.refinement_edit)
672
- self.label3 = QLabel(" µm ")
673
- self.refinement_layout.addWidget(self.label3)
796
+ self.refined_override_btn = QPushButton("Advanced...")
797
+ self.refined_override_btn.clicked.connect(self.open_refined_cellsize_override_dialog)
798
+ self.refinement_layout.addWidget(self.refined_override_btn)
674
799
  self.refinement_layout.addStretch()
675
800
  self.mesh_layout.addLayout(self.refinement_layout)
676
801
 
802
+ # in-memory copy of settings['refined_cellsize_override'] (list of
803
+ # [layername, value] pairs), edited via the "Advanced..." dialog;
804
+ # harvested into saved_values by save_values() like every other field
805
+ self._refined_cellsize_override = []
806
+
677
807
  self.cells_lambda_layout = QHBoxLayout()
678
- self.label4 = QLabel("Mesh cells per wavelength")
808
+ self.label4 = QLabel("Mesh cells per wavelength (min 10)")
679
809
  self.label4.setFixedWidth(label_width)
680
810
  self.cells_lambda_layout.addWidget(self.label4)
681
811
  self.cells_lambda_edit = QLineEdit("10")
682
812
  self.cells_lambda_edit.setFixedWidth(edit_width)
683
813
  self.cells_lambda_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
684
814
  self.cells_lambda_layout.addWidget(self.cells_lambda_edit)
685
- self.label5 = QLabel(" (min 10)")
686
- self.cells_lambda_layout.addWidget(self.label5)
687
815
  self.cells_lambda_layout.addStretch()
688
816
  self.mesh_layout.addLayout(self.cells_lambda_layout)
689
817
 
690
818
 
691
819
  self.cells_maxsize_layout = QHBoxLayout()
692
- self.label6 = QLabel("Mesh cell maximum size absolute")
820
+ self.label6 = QLabel("Mesh cell maximum size absolute (µm)")
693
821
  self.label6.setFixedWidth(label_width)
694
822
  self.cells_maxsize_layout.addWidget(self.label6)
695
823
  self.cells_maxsize_edit = QLineEdit("100")
696
824
  self.cells_maxsize_edit.setFixedWidth(edit_width)
697
825
  self.cells_maxsize_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
698
826
  self.cells_maxsize_layout.addWidget(self.cells_maxsize_edit)
699
- self.label5 = QLabel(" µm ")
700
- self.cells_maxsize_layout.addWidget(self.label5)
701
827
  self.cells_maxsize_layout.addStretch()
702
828
  self.mesh_layout.addLayout(self.cells_maxsize_layout)
703
829
 
@@ -916,6 +1042,22 @@ class MeshTab(QWidget):
916
1042
  self.setLayout(self.main_layout)
917
1043
 
918
1044
 
1045
+ def _update_refined_override_button_label(self):
1046
+ n = len(self._refined_cellsize_override)
1047
+ self.refined_override_btn.setText(f"Advanced... ({n})" if n > 0 else "Advanced...")
1048
+
1049
+ def open_refined_cellsize_override_dialog(self):
1050
+ metals_list = self.MainWindow.metals_list
1051
+ if metals_list is None:
1052
+ QMessageBox.warning(self, "Error", "Load a GDSII file and XML stackup first")
1053
+ return
1054
+
1055
+ layer_choices = [metal.name for metal in metals_list.getallplanarmetals()]
1056
+ dialog = RefinedCellsizeOverrideDialog(self, layer_choices, self._refined_cellsize_override)
1057
+ if dialog.exec() == QDialog.Accepted:
1058
+ self._refined_cellsize_override = dialog.get_overrides()
1059
+ self._update_refined_override_button_label()
1060
+
919
1061
  def on_meshorder_changed(self, value):
920
1062
  # callback when mesh order changed, so that we can show/hide edit fields
921
1063
  try:
@@ -952,6 +1094,7 @@ class MeshTab(QWidget):
952
1094
  self.refinement_edit.setText("5")
953
1095
  return False
954
1096
  saved_values ["refined_cellsize"] = float(value)
1097
+ saved_values ["refined_cellsize_override"] = self._refined_cellsize_override
955
1098
 
956
1099
  saved_values ["order"] = self.mesh_order_box.currentIndex()+1
957
1100
 
@@ -1053,6 +1196,8 @@ class MeshTab(QWidget):
1053
1196
 
1054
1197
  def load_values(self):
1055
1198
  self.refinement_edit.setText(str(saved_values.get("refined_cellsize","5")))
1199
+ self._refined_cellsize_override = list(saved_values.get("refined_cellsize_override", []))
1200
+ self._update_refined_override_button_label()
1056
1201
  self.cells_lambda_edit.setText(str(saved_values.get("cells_per_wavelength","10")))
1057
1202
  self.cells_maxsize_edit.setText(str(saved_values.get("meshsize_max","100")))
1058
1203
  self.AMR_iterations_edit.setText(str(saved_values.get("adaptive_mesh_iterations","0")))
@@ -1080,17 +1225,23 @@ class MeshTab(QWidget):
1080
1225
  self.airaround_edit.setText(saved_values.get("margin","200"))
1081
1226
  self.airaround_box.setCurrentIndex(0)
1082
1227
  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])
1228
+ # native JSON round-trip stores this as a real list of floats;
1229
+ # .py import stores it as a comma-separated string instead
1230
+ if isinstance(air, list):
1231
+ air_as_list = [str(v) for v in air]
1232
+ elif "," in str(air):
1233
+ air_as_list = [v.strip() for v in str(air).split(',')]
1234
+ else:
1235
+ air_as_list = None
1236
+
1237
+ if air_as_list and len(air_as_list) == 6:
1238
+ self.airaround_box.setCurrentIndex(1)
1239
+ self.airxmin_edit.setText(air_as_list[0])
1240
+ self.airxmax_edit.setText(air_as_list[1])
1241
+ self.airymin_edit.setText(air_as_list[2])
1242
+ self.airymax_edit.setText(air_as_list[3])
1243
+ self.airzmin_edit.setText(air_as_list[4])
1244
+ self.airzmax_edit.setText(air_as_list[5])
1094
1245
  else:
1095
1246
  # we have air_around defined as a single value
1096
1247
  self.airaround_box.setCurrentIndex(0)
@@ -1171,9 +1322,9 @@ class CreateModelTab(CreateModelTabBase):
1171
1322
  # local import: only needed once snp2le is actually launched, same lazy-import
1172
1323
  # style used by open_result_viewer() for its own heavy imports.
1173
1324
  if __package__ in (None, ""):
1174
- from result_viewer import find_touchstone_files
1325
+ from result_viewer import find_touchstone_files, is_amr_iteration_snapshot
1175
1326
  else:
1176
- from .result_viewer import find_touchstone_files
1327
+ from .result_viewer import find_touchstone_files, is_amr_iteration_snapshot
1177
1328
 
1178
1329
  if self.MainWindow.PalaceMode:
1179
1330
  run_path = saved_values['sim_path'] + "/palace_model/" + saved_values['model_basename'] + "_data"
@@ -1189,23 +1340,28 @@ class CreateModelTab(CreateModelTabBase):
1189
1340
  QMessageBox.warning(self, "Model Fit",
1190
1341
  f"No raw S-parameter result file found in {run_path}.\nRun a simulation first.")
1191
1342
  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:
1343
+
1344
+ # adaptive mesh refinement leaves one snapshot touchstone file per
1345
+ # iteration<N>/ subfolder alongside the final, fully-refined result
1346
+ # directly in run_path - prefer that final result over the snapshots.
1347
+ final_candidates = [p for p in raw_files if not is_amr_iteration_snapshot(p)] or raw_files
1348
+ if len(final_candidates) > 1:
1349
+ self.log_area.appendPlainText("⚠️ Multiple raw S-parameter result files found, using the newest one:")
1350
+ for path in final_candidates:
1195
1351
  self.log_area.appendPlainText(" " + path)
1196
- raw_file = raw_files[0]
1352
+ raw_file = max(final_candidates, key=os.path.getmtime)
1197
1353
 
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.
1354
+ # naming a .sNp file on the command line opens snp2le's GUI on it directly
1355
+ # (see https://github.com/iic-jku/snp2le/blob/main/doc/architecture.md);
1356
+ # still start it in that file's directory, so any relative paths it uses
1357
+ # (e.g. for exporting a fit) resolve there instead of wherever setupEM
1358
+ # happened to be launched from.
1202
1359
  raw_dir = os.path.dirname(raw_file)
1203
- self.log_area.appendPlainText("Starting snp2le ...")
1360
+ self.log_area.appendPlainText(f"Starting snp2le on {raw_file} ...")
1204
1361
  self.log_area.appendPlainText(SNP2LE_URL)
1205
- self.log_area.appendPlainText(f"In the snp2le window, load: {raw_file}")
1206
1362
  self._process_purpose = "model_fit"
1207
1363
  self.process.setWorkingDirectory(raw_dir)
1208
- self.process.start(sys.executable, ["-m", "snp2le"])
1364
+ self.process.start(sys.executable, ["-m", "snp2le", raw_file])
1209
1365
 
1210
1366
  def _append_results_summary(self):
1211
1367
  # Palace-only: parse palace.json / error-indicators.csv and append a results summary
@@ -1516,7 +1672,7 @@ class ModelEditorTab(QWidget):
1516
1672
 
1517
1673
  if forExport:
1518
1674
  # these commands are only used within this GUI application to control gmsh
1519
- ignore_list.append(['preview_only','no_preview'])
1675
+ ignore_list.extend(['preview_only','no_preview'])
1520
1676
 
1521
1677
  for key in saved_values.keys():
1522
1678
  if not key in special_keylist:
@@ -1802,20 +1958,30 @@ class MainWindow(MainWindowBase):
1802
1958
  def apply_native_config_data(self, data):
1803
1959
  # update ports, they are separate from the other internal data
1804
1960
  self.ports_tab.update_port_from_import(data.get("ports"))
1961
+ # restore simulator mode (not part of saved_values, see native_config_extra_struct)
1962
+ if data.get("elmer_mode", False):
1963
+ self.setElmerMode()
1964
+ else:
1965
+ self.setPalaceMode()
1805
1966
 
1806
1967
  def apply_python_import_data(self, file_path):
1807
1968
  # read port assignments in workflow syntax for gds2palace Python code
1808
1969
  ports = parse_python_ports_definitions(file_path)
1809
1970
  self.ports_tab.update_port_from_import(ports)
1810
1971
 
1811
- # set simulator
1812
- if self.saved_values.get("elmer", False):
1972
+ # Elmer/Palace mode is expressed only by which simulation_setup.create_*()
1973
+ # call the script makes, never as a literal settings['elmer'] assignment
1974
+ # (parse_assignments() skips lines whose value contains "settings"), so
1975
+ # detect it directly from the source text instead.
1976
+ with open(file_path) as f:
1977
+ text = f.read()
1978
+ if "create_elmer(" in text:
1813
1979
  self.setElmerMode()
1814
1980
  else:
1815
1981
  self.setPalaceMode()
1816
1982
 
1817
1983
  def native_config_extra_struct(self):
1818
- return {"ports": simulation_ports_to_struct(simulation_ports)}
1984
+ return {"ports": simulation_ports_to_struct(simulation_ports), "elmer_mode": self.ElmerMode}
1819
1985
 
1820
1986
  def update_target_layer_choices(self, metals_list):
1821
1987
  self.ports_tab.update_layers(metals_list)
@@ -486,7 +486,7 @@ class MeshTab(QWidget):
486
486
  self.label6 = QLabel("Mesh cell maximum size")
487
487
  self.label6.setFixedWidth(label_width)
488
488
  self.cells_maxsize_layout.addWidget(self.label6)
489
- self.cells_maxsize_edit = QLineEdit("10")
489
+ self.cells_maxsize_edit = QLineEdit("100")
490
490
  self.cells_maxsize_edit.setFixedWidth(edit_width)
491
491
  self.cells_maxsize_edit.setStyleSheet(EDIT_STYLE_OPTIONAL)
492
492
  self.cells_maxsize_layout.addWidget(self.cells_maxsize_edit)
@@ -507,7 +507,7 @@ class MeshTab(QWidget):
507
507
  self.label7 = QLabel("Dielectric stackup: oversize by")
508
508
  self.label7.setFixedWidth(label_width)
509
509
  self.margins_layout.addWidget(self.label7)
510
- self.margins_edit = QLineEdit("200")
510
+ self.margins_edit = QLineEdit("100")
511
511
  self.margins_edit.setFixedWidth(edit_width)
512
512
  self.margins_edit.setStyleSheet(EDIT_STYLE_REQUIRED)
513
513
  self.margins_layout.addWidget(self.margins_edit)
@@ -545,7 +545,7 @@ class MeshTab(QWidget):
545
545
  value = float(self.margins_edit.text())
546
546
  except Exception:
547
547
  QMessageBox.warning(self, "Error", "Not a valid value for dielectric oversize margin")
548
- self.margins_edit.setText("200")
548
+ self.margins_edit.setText("100")
549
549
  return False
550
550
  saved_values ["margin"] = float(value)
551
551
 
@@ -737,7 +737,7 @@ class ModelEditorTab(QWidget):
737
737
 
738
738
  if forExport:
739
739
  # these commands are only used within this GUI application to control gmsh
740
- ignore_list.append(['preview_only','no_preview'])
740
+ ignore_list.extend(['preview_only','no_preview'])
741
741
 
742
742
  for key in saved_values.keys():
743
743
  if not key in special_keylist:
@@ -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
 
@@ -1411,6 +1411,42 @@ class MainWindowBase(QMainWindow):
1411
1411
 
1412
1412
  TAB_HEADER_COLORS = ["#FFCDD2", "#C8E6C9", "#BBDEFB", "#FFF9C4", "#D1C4E9"]
1413
1413
 
1414
+ def __init__(self):
1415
+ super().__init__()
1416
+ # let the whole window accept a dropped *.simcfg / *.tsimcfg file,
1417
+ # not just the individual file-path fields (see FileDropLineEdit)
1418
+ self.setAcceptDrops(True)
1419
+
1420
+ # ---------- Drag & drop native config file onto the window ----------
1421
+ def _config_file_from_drop(self, event):
1422
+ if not event.mimeData().hasUrls():
1423
+ return None
1424
+ for url in event.mimeData().urls():
1425
+ path = url.toLocalFile()
1426
+ if path and pathlib.Path(path).suffix.upper() == "." + self.CONFIG_SUFFIX.upper():
1427
+ return path
1428
+ return None
1429
+
1430
+ def dragEnterEvent(self, event):
1431
+ if self._config_file_from_drop(event):
1432
+ event.acceptProposedAction()
1433
+ else:
1434
+ event.ignore()
1435
+
1436
+ def dragMoveEvent(self, event):
1437
+ if self._config_file_from_drop(event):
1438
+ event.acceptProposedAction()
1439
+ else:
1440
+ event.ignore()
1441
+
1442
+ def dropEvent(self, event):
1443
+ file_path = self._config_file_from_drop(event)
1444
+ if file_path:
1445
+ event.acceptProposedAction()
1446
+ self.load_configuration_from_file(file_path)
1447
+ else:
1448
+ event.ignore()
1449
+
1414
1450
  # ---------- Menu Bar ----------
1415
1451
  def create_menu_bar(self):
1416
1452
  menu_bar = self.menuBar()
@@ -1633,6 +1669,8 @@ class MainWindowBase(QMainWindow):
1633
1669
  "XML_filename": "SubstrateFile",
1634
1670
  "GdsFile": "GdsFile",
1635
1671
  "purpose": "purpose",
1672
+ "cellname": "cellname",
1673
+ "variable_overrides": "variable_overrides",
1636
1674
  "SubstrateFile": "SubstrateFile",
1637
1675
  "merge_polygon_size": "merge_polygon_size",
1638
1676
  "preprocess_gds": "preprocess_gds",
@@ -1645,12 +1683,12 @@ class MainWindowBase(QMainWindow):
1645
1683
  "fpoint": "fpoint",
1646
1684
  "fdump": "fdump",
1647
1685
  "refined_cellsize": "refined_cellsize",
1686
+ "refined_cellsize_override": "refined_cellsize_override",
1648
1687
  "cells_per_wavelength": "cells_per_wavelength",
1649
1688
  "meshsize_max": "meshsize_max",
1650
1689
  "adaptive_mesh_iterations": "adaptive_mesh_iterations",
1651
1690
  "order": "order",
1652
1691
  "iterative": "iterative",
1653
- "elmer": "elmer",
1654
1692
  "ELMER_MPI_THREADS": "ELMER_MPI_THREADS"
1655
1693
  }
1656
1694
 
@@ -1670,7 +1708,7 @@ class MainWindowBase(QMainWindow):
1670
1708
  if import_key not in import_value: # skip the section where key might appear in different context
1671
1709
  # get the internal name for this variable
1672
1710
  varname = import_mapping.get(import_key, '')
1673
- if varname == "fpoint" or varname == "fdump":
1711
+ if varname in ("fpoint", "fdump", "variable_overrides", "refined_cellsize_override"):
1674
1712
  saved_values[varname] = ast.literal_eval(import_value)
1675
1713
  elif varname in ["gds_filename", "XML_filename", "GdsFile", "SubstrateFile"]:
1676
1714
  # check if we have full path for files in imported Python script,
@@ -1691,6 +1729,19 @@ class MainWindowBase(QMainWindow):
1691
1729
  else:
1692
1730
  saved_values[varname] = raw
1693
1731
 
1732
+ # ask whether future "Create Model" output should overwrite this same
1733
+ # file, or start a fresh model (today's GDS-derived default)
1734
+ reuse = QMessageBox.question(
1735
+ self, "Import Model",
1736
+ f"Use '{os.path.basename(file_path)}' as the output file for this model too?\n\n"
1737
+ "Yes: Create Model / Start Simulation will overwrite this file.\n"
1738
+ "No: pick a model name and target directory on the Create Model(s) tab.",
1739
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No
1740
+ ) == QMessageBox.Yes
1741
+ if reuse:
1742
+ saved_values['sim_path'] = os.path.dirname(file_path).replace('\\', '/')
1743
+ saved_values['model_basename'] = pathlib.Path(file_path).stem
1744
+
1694
1745
  # read port/thermal assignments in workflow syntax for gds2palace Python code, and
1695
1746
  # apply any app-specific post-import state (e.g. setupEM's simulator mode)
1696
1747
  self.apply_python_import_data(file_path)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.5.0
3
+ Version: 0.6.0
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes