setupEM 0.7.2__tar.gz → 0.7.3__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 (31) hide show
  1. {setupem-0.7.2/src/setupEM.egg-info → setupem-0.7.3}/PKG-INFO +1 -1
  2. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/__init__.py +1 -1
  3. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/field_viewer.py +305 -31
  4. setupem-0.7.3/src/setupEM/gds_hierarchy_scan.py +135 -0
  5. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/layout_preview.py +42 -0
  6. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setup_common.py +62 -5
  7. {setupem-0.7.2 → setupem-0.7.3/src/setupEM.egg-info}/PKG-INFO +1 -1
  8. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/SOURCES.txt +1 -0
  9. {setupem-0.7.2 → setupem-0.7.3}/LICENSE +0 -0
  10. {setupem-0.7.2 → setupem-0.7.3}/README.md +0 -0
  11. {setupem-0.7.2 → setupem-0.7.3}/README_pypi.md +0 -0
  12. {setupem-0.7.2 → setupem-0.7.3}/pyproject.toml +0 -0
  13. {setupem-0.7.2 → setupem-0.7.3}/setup.cfg +0 -0
  14. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/__main__.py +0 -0
  15. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_100um.xml +0 -0
  16. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_200um.xml +0 -0
  17. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  18. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  19. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/momentum_import.py +0 -0
  20. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/palace_results.py +0 -0
  21. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/result_viewer.py +0 -0
  22. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setupEM.py +0 -0
  23. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setupThermal.py +0 -0
  24. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/simplify_gds.py +0 -0
  25. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/stackupEditor.py +0 -0
  26. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/stackup_writer.py +0 -0
  27. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/thermal_results.py +0 -0
  28. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/dependency_links.txt +0 -0
  29. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/entry_points.txt +0 -0
  30. {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/requires.txt +0 -0
  31. {setupem-0.7.2 → setupem-0.7.3}/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.7.2
3
+ Version: 0.7.3
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.7.2"
27
+ __version__ = "0.7.3"
@@ -52,6 +52,7 @@ import argparse
52
52
  import glob
53
53
  import os
54
54
  import sys
55
+ import time
55
56
 
56
57
  import numpy as np
57
58
  import pyvista as pv
@@ -63,6 +64,7 @@ from PySide6.QtWidgets import (
63
64
  QSlider, QComboBox, QLineEdit, QStyleFactory,
64
65
  )
65
66
  from PySide6.QtCore import Qt, QThread, Signal
67
+ from PySide6.QtGui import QShortcut, QKeySequence
66
68
 
67
69
  # __package__ is None/"" when this file is run directly rather than imported as part
68
70
  # of the setupEM package, so relative import fails - same dual-mode pattern used
@@ -98,6 +100,10 @@ _VIEW_UP = {"X": (0.0, 0.0, 1.0), "Y": (0.0, 0.0, 1.0), "Z": (0.0, 1.0, 0.0)}
98
100
  # its integer step count.
99
101
  _SLIDER_STEPS = 1_000_000
100
102
 
103
+ # Pixel size for the plain pv.Plotter used in --screenshot/headless mode (see
104
+ # FieldViewerWindow._build_ui()) - no GUI window to inherit a size from there.
105
+ _SCREENSHOT_WINDOW_SIZE = (1280, 960)
106
+
101
107
  # Sources this viewer knows a tailored default array/colormap for. Anything else
102
108
  # (not currently reachable from the app, but kept open for standalone use) falls
103
109
  # back to the generic "first available array" behavior in _pick_default_array().
@@ -133,6 +139,31 @@ _E_FIELD_COMPLEX_KEYS = {
133
139
  _ELMER_EM: ("electric field re", "electric field im"),
134
140
  }
135
141
 
142
+ # Real-part array name per source, for the CLI's --field e/b/s shorthand (see
143
+ # _resolve_field_shorthand()) - same naming mismatch as _E_FIELD_COMPLEX_KEYS
144
+ # above, just for the real-only part plus B-field and the Poynting vector.
145
+ # "s" has no Elmer-EM entry: that field dump has no Poynting-vector array
146
+ # (confirmed against a real Elmer-EM field dump - only E/B field re/im).
147
+ # "temp" isn't here at all - Elmer thermal's temperature array has no fixed
148
+ # name, resolved via the same substring match _pick_default_array() uses.
149
+ _FIELD_REAL_KEY = {
150
+ "e": {_PALACE: "E_real", _ELMER_EM: "electric field re"},
151
+ "b": {_PALACE: "B_real", _ELMER_EM: "magnetic flux density re"},
152
+ "s": {_PALACE: "S"},
153
+ }
154
+
155
+ # Arrays where a dB range means 20*log10(ratio) (field/amplitude convention)
156
+ # rather than 10*log10(ratio) (power/intensity convention) - see
157
+ # _db_range_to_clim(). Built from _FIELD_REAL_KEY's E/B entries plus their
158
+ # imaginary-part/magnitude counterparts; anything not in this set (S, U_e,
159
+ # U_m, an unrecognized --array value) uses the power convention instead.
160
+ _FIELD_AMPLITUDE_ARRAYS = {
161
+ "E_real", "E_imag", "E_magnitude", "B_real", "B_imag",
162
+ "electric field re", "electric field im",
163
+ "magnetic flux density re", "magnetic flux density im",
164
+ "magnetic field strength re", "magnetic field strength im",
165
+ }
166
+
136
167
  # Vector-arrow (glyph) overlay auto-sizing: the largest arrow is scaled to span
137
168
  # a percentage of the mesh's own bounding-box diagonal (the "Arrow size"
138
169
  # slider, in percent - see _add_vector_glyphs()), regardless of the selected
@@ -143,11 +174,13 @@ _E_FIELD_COMPLEX_KEYS = {
143
174
  # size no matter which vector field or domain scale is loaded, and the slider
144
175
  # then lets the user scale that up or down to taste.
145
176
  _VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT = 8
146
- # QSlider is integer-only, so a 0.5% step is represented as an integer count
147
- # of half-percent units internally (arrow_size_slider's range/value are in
177
+ # QSlider is integer-only, so a 0.2% step is represented as an integer count
178
+ # of fifth-percent units internally (arrow_size_slider's range/value are in
148
179
  # these units) - see _on_arrow_size_changed()/_add_vector_glyphs() for the
149
180
  # conversion back to a plain percentage.
150
- _ARROW_SIZE_STEP_PERCENT = 0.5
181
+ _ARROW_SIZE_STEP_PERCENT = 0.2
182
+ _ARROW_SIZE_MIN_PERCENT = 0.2
183
+ _ARROW_SIZE_MAX_PERCENT = 10
151
184
  # Shortest arrow (smallest-magnitude point actually glyphed) is still drawn at
152
185
  # this fraction of the longest arrow's length, rather than shrinking toward
153
186
  # zero - see _add_vector_glyphs() for why a raw linear magnitude->length
@@ -293,6 +326,47 @@ def _pick_default_array(mesh, source):
293
326
  return (available[0], "viridis", False) if available else (None, "viridis", False)
294
327
 
295
328
 
329
+ def _resolve_field_shorthand(shorthand, mesh, source):
330
+ """Map a CLI --field shorthand ("e"/"b"/"s"/"temp") to an actual
331
+ point-data array name present in mesh, for this source. Returns None if
332
+ the shorthand doesn't apply to this source (e.g. "s" for Elmer-EM, which
333
+ has no Poynting-vector array) or the expected array isn't actually in
334
+ this particular file - the caller turns that into a clear CLI error
335
+ rather than silently keeping whatever array was already selected.
336
+ "temp" has no fixed name (same as _pick_default_array()'s own handling),
337
+ so it's resolved via substring match instead of _FIELD_REAL_KEY.
338
+ """
339
+ if shorthand == "temp":
340
+ key = next((k for k in mesh.point_data.keys() if "temp" in k.lower()), None)
341
+ else:
342
+ key = _FIELD_REAL_KEY.get(shorthand, {}).get(source)
343
+ return key if key is not None and key in mesh.point_data else None
344
+
345
+
346
+ def _is_field_amplitude_array(array_name):
347
+ """True for an E/B-field array (dB range uses 20*log10 - see
348
+ _db_range_to_clim()), False for anything else (S, U_e, U_m, an
349
+ unrecognized --array value), which uses 10*log10 instead."""
350
+ return array_name in _FIELD_AMPLITUDE_ARRAYS
351
+
352
+
353
+ def _db_range_to_clim(mesh, array_name, db_range):
354
+ """(floor, data_max) for a log color scale spanning db_range dB below
355
+ array_name's own maximum in mesh, using the field-amplitude convention
356
+ (20*log10) for E/B-field arrays and the power/intensity convention
357
+ (10*log10) for everything else - see _is_field_amplitude_array(). Returns
358
+ None if the array has no positive values to scale from (nothing sensible
359
+ to show on a log scale)."""
360
+ magnitudes = _array_magnitudes(mesh[array_name])
361
+ positive = magnitudes[magnitudes > 0]
362
+ if positive.size == 0:
363
+ return None
364
+ data_max = positive.max()
365
+ db_per_decade = 20.0 if _is_field_amplitude_array(array_name) else 10.0
366
+ floor = data_max / 10.0 ** (db_range / db_per_decade)
367
+ return floor, data_max
368
+
369
+
296
370
  # ------------------------------------------------------------------
297
371
  # Field Viewer window
298
372
  # ------------------------------------------------------------------
@@ -305,13 +379,19 @@ class FieldViewerWindow(QDialog):
305
379
  inherently the "right" one to default to, so the user picks) - with a single
306
380
  axis-aligned clip plane and a field/array picker."""
307
381
 
308
- def __init__(self, MainWindow, file_paths, source):
382
+ def __init__(self, MainWindow, file_paths, source, off_screen=False):
309
383
  super().__init__()
310
384
  self.setAttribute(Qt.WA_DeleteOnClose)
311
385
  self.MainWindow = MainWindow
312
386
  self.file_paths = list(file_paths)
313
387
  self.file_path = self.file_paths[0]
314
388
  self.source = source
389
+ # CLI-only (see main()'s --screenshot): render off-screen instead of
390
+ # in a real, visible window - lets field_viewer.py run headless, e.g.
391
+ # in an agent/script context with no display. The GUI (setupEM.py/
392
+ # setupThermal.py's open_field_viewer()) never passes this, so it
393
+ # defaults to the normal on-screen behavior there.
394
+ self._off_screen = off_screen
315
395
 
316
396
  self._full_mesh = None
317
397
  self._mesh_actor = None
@@ -483,10 +563,31 @@ class FieldViewerWindow(QDialog):
483
563
  self.opacity_slider.setValue(100)
484
564
  self.opacity_slider.valueChanged.connect(self._on_opacity_changed)
485
565
  display_layout.addWidget(self.opacity_slider)
566
+
567
+ self.arrow_size_label = QLabel(f"Arrow size: {_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT}%")
568
+ self.arrow_size_label.setEnabled(False)
569
+ display_layout.addWidget(self.arrow_size_label)
570
+ self.arrow_size_slider = QSlider(Qt.Horizontal)
571
+ self.arrow_size_slider.setRange(round(_ARROW_SIZE_MIN_PERCENT / _ARROW_SIZE_STEP_PERCENT),
572
+ round(_ARROW_SIZE_MAX_PERCENT / _ARROW_SIZE_STEP_PERCENT))
573
+ self.arrow_size_slider.setValue(round(_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT / _ARROW_SIZE_STEP_PERCENT))
574
+ self.arrow_size_slider.setEnabled(False)
575
+ self.arrow_size_slider.valueChanged.connect(self._on_arrow_size_changed)
576
+ display_layout.addWidget(self.arrow_size_slider)
577
+
578
+ # Only meaningful (and enabled) when the selected Field array is itself
579
+ # a vector (e.g. E_real/E_imag/B_real/B_imag/S) rather than a scalar
580
+ # (e.g. E_magnitude/U_e/temperature) - see _update_vector_checkbox_state().
581
+ self.show_vectors_cb = QCheckBox("Show arrows")
582
+ self.show_vectors_cb.setEnabled(False)
583
+ self.show_vectors_cb.toggled.connect(self._on_redraw_needed)
584
+ display_layout.addWidget(self.show_vectors_cb)
585
+
486
586
  self.show_edges_cb = QCheckBox("Overlay mesh")
487
587
  self.show_edges_cb.setChecked(False)
488
588
  self.show_edges_cb.toggled.connect(self._on_redraw_needed)
489
589
  display_layout.addWidget(self.show_edges_cb)
590
+
490
591
  display_layout.addStretch()
491
592
  display_group.setLayout(display_layout)
492
593
  controls_layout.addWidget(display_group, 1)
@@ -503,16 +604,22 @@ class FieldViewerWindow(QDialog):
503
604
  # Color range (Min/Max/Reset) directly below the array/log-scale
504
605
  # controls it applies to - vector-arrow controls (a separate concern)
505
606
  # follow below, rather than interleaving the two.
506
- clim_layout = QHBoxLayout()
507
- clim_layout.addWidget(QLabel("Min:"))
607
+ # Min/Max stacked one per row (not side by side) - two QLineEdits
608
+ # sharing one row left each too narrow to read its own value
609
+ # comfortably, given the whole Field group's fixed column width.
610
+ min_layout = QHBoxLayout()
611
+ min_layout.addWidget(QLabel("Min:"))
508
612
  self.clim_min_edit = QLineEdit()
509
613
  self.clim_min_edit.editingFinished.connect(self._on_redraw_needed)
510
- clim_layout.addWidget(self.clim_min_edit)
511
- clim_layout.addWidget(QLabel("Max:"))
614
+ min_layout.addWidget(self.clim_min_edit)
615
+ field_layout.addLayout(min_layout)
616
+
617
+ max_layout = QHBoxLayout()
618
+ max_layout.addWidget(QLabel("Max:"))
512
619
  self.clim_max_edit = QLineEdit()
513
620
  self.clim_max_edit.editingFinished.connect(self._on_redraw_needed)
514
- clim_layout.addWidget(self.clim_max_edit)
515
- field_layout.addLayout(clim_layout)
621
+ max_layout.addWidget(self.clim_max_edit)
622
+ field_layout.addLayout(max_layout)
516
623
  self.clim_reset_btn = QPushButton("Reset range to data")
517
624
  # Without this, Qt treats this as the dialog's default button (the only
518
625
  # QPushButton in the window) and fires it on Enter from *any* focused
@@ -523,24 +630,6 @@ class FieldViewerWindow(QDialog):
523
630
  self.clim_reset_btn.clicked.connect(self._on_clim_reset_clicked)
524
631
  field_layout.addWidget(self.clim_reset_btn)
525
632
 
526
- # Only meaningful (and enabled) when the selected Field array is itself
527
- # a vector (e.g. E_real/E_imag/B_real/B_imag/S) rather than a scalar
528
- # (e.g. E_magnitude/U_e/temperature) - see _update_vector_checkbox_state().
529
- self.show_vectors_cb = QCheckBox("Show arrows")
530
- self.show_vectors_cb.setEnabled(False)
531
- self.show_vectors_cb.toggled.connect(self._on_redraw_needed)
532
- field_layout.addWidget(self.show_vectors_cb)
533
-
534
- self.arrow_size_label = QLabel(f"Arrow size: {_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT}%")
535
- self.arrow_size_label.setEnabled(False)
536
- field_layout.addWidget(self.arrow_size_label)
537
- self.arrow_size_slider = QSlider(Qt.Horizontal)
538
- self.arrow_size_slider.setRange(round(1 / _ARROW_SIZE_STEP_PERCENT), round(10 / _ARROW_SIZE_STEP_PERCENT))
539
- self.arrow_size_slider.setValue(round(_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT / _ARROW_SIZE_STEP_PERCENT))
540
- self.arrow_size_slider.setEnabled(False)
541
- self.arrow_size_slider.valueChanged.connect(self._on_arrow_size_changed)
542
- field_layout.addWidget(self.arrow_size_slider)
543
-
544
633
  field_layout.addStretch()
545
634
  field_group.setLayout(field_layout)
546
635
  controls_layout.addWidget(field_group, 1)
@@ -577,8 +666,32 @@ class FieldViewerWindow(QDialog):
577
666
  self.warning_label.setStyleSheet("color: #b00000;")
578
667
  main_layout.addWidget(self.warning_label)
579
668
 
580
- self.plotter = QtInteractor(self)
581
- main_layout.addWidget(self.plotter, 1)
669
+ if self._off_screen:
670
+ # QtInteractor's off_screen mode still needs a real OpenGL context,
671
+ # which Qt only creates via a widget's paint/show machinery - never
672
+ # firing for a widget that's added to a layout but never actually
673
+ # shown (confirmed empirically: the render window gets its
674
+ # requested size but stays solid black, nothing ever drawn into
675
+ # it). A plain pv.Plotter(off_screen=True) has no such dependency
676
+ # on Qt's window system at all - real off-screen VTK rendering,
677
+ # confirmed working - so use that instead for CLI/headless use
678
+ # (--screenshot). It exposes the same add_mesh()/remove_actor()/
679
+ # render()/reset_camera()/camera_position/view_isometric()/
680
+ # screenshot()/close() API the rest of this class already calls
681
+ # generically on self.plotter, so no other method needs to
682
+ # branch on which one it is - it's just never added to
683
+ # main_layout since it isn't a QWidget.
684
+ self.plotter = pv.Plotter(off_screen=True, window_size=_SCREENSHOT_WINDOW_SIZE)
685
+ else:
686
+ self.plotter = QtInteractor(self)
687
+ main_layout.addWidget(self.plotter, 1)
688
+
689
+ # Ctrl+C copies the 3D view itself (not the control panels) to the
690
+ # clipboard as an image - window-scoped (default QShortcut context) so
691
+ # it fires regardless of which child widget currently has focus, same
692
+ # convention as layout_preview.py/result_viewer.py/stackupEditor.py.
693
+ QShortcut(QKeySequence.Copy, self).activated.connect(
694
+ lambda: QApplication.clipboard().setPixmap(self.plotter.grab()))
582
695
 
583
696
  # ---------- Result file picker ----------
584
697
 
@@ -680,6 +793,23 @@ class FieldViewerWindow(QDialog):
680
793
  fraction = self.clip_slider.value() / _SLIDER_STEPS
681
794
  return lo + fraction * (hi - lo)
682
795
 
796
+ def _position_um_to_slider_value(self, axis, position_um):
797
+ """Exact inverse of _slider_value_to_position(), for the given axis -
798
+ convert a desired position in um (matching the GUI's own display
799
+ units, see _POSITION_SCALE_TO_UM) into the clip_slider's integer step
800
+ value. Used by the CLI's --clip-position; clamped to [0, 1] the same
801
+ way _move_slider_to_max() already clamps its own fraction, so an
802
+ out-of-bounds position lands at the nearest edge instead of erroring.
803
+ """
804
+ if self._full_mesh is None:
805
+ return _SLIDER_STEPS // 2
806
+ position_native = position_um / _POSITION_SCALE_TO_UM.get(self.source, 1.0)
807
+ lo_idx, hi_idx = _AXIS_BOUNDS_INDEX[axis]
808
+ lo, hi = self._full_mesh.bounds[lo_idx], self._full_mesh.bounds[hi_idx]
809
+ fraction = (position_native - lo) / (hi - lo) if hi > lo else 0.5
810
+ fraction = min(max(fraction, 0.0), 1.0)
811
+ return round(fraction * _SLIDER_STEPS)
812
+
683
813
  def _on_redraw_needed(self, _value=None):
684
814
  self._redraw()
685
815
 
@@ -1123,8 +1253,58 @@ def main():
1123
1253
  "in, instead of passing file_path directly")
1124
1254
  parser.add_argument("--source", choices=[_PALACE, _ELMER_EM, _ELMER_THERMAL], default=_PALACE,
1125
1255
  help="which default array/colormap preset to use (default: palace)")
1256
+
1257
+ # --- Scripted/agentic use: everything below sets up the view without a
1258
+ # human touching the GUI, by driving the same widgets a click would - see
1259
+ # _redraw()'s docstring for why that's safe/correct to do programmatically.
1260
+ parser.add_argument("--field", choices=["e", "b", "s", "temp"],
1261
+ help="shorthand array selection: e=E-field real, b=B-field real, "
1262
+ "s=Poynting vector (Palace only), temp=temperature (Elmer "
1263
+ "thermal only). Ignored if --array is also given.")
1264
+ parser.add_argument("--array", help="exact point-data array name to color by "
1265
+ "(e.g. E_imag, U_e, B_imag) - overrides --field")
1266
+ parser.add_argument("--log-scale", action="store_true", help="use a log color scale")
1267
+ parser.add_argument("--log-range-db", type=float,
1268
+ help="set the color range's minimum this many dB below the "
1269
+ "data's own maximum (implies --log-scale) instead of using "
1270
+ "the data's own minimum - 20*log10 for E/B-field arrays, "
1271
+ "10*log10 for everything else (S, energy density, ...)")
1272
+ parser.add_argument("--clip-axis", choices=["X", "Y", "Z"],
1273
+ help="enable the clip plane on this axis")
1274
+ parser.add_argument("--clip-position", type=float,
1275
+ help="clip plane position in um along --clip-axis (requires it)")
1276
+ parser.add_argument("--clip-max", action="store_true",
1277
+ help="move the clip plane to the hotspot (largest value of the "
1278
+ "selected field) along --clip-axis, like Find max. (requires it)")
1279
+ parser.add_argument("--arrows", action="store_true",
1280
+ help="overlay vector-field direction arrows (only takes effect "
1281
+ "when the selected array is a vector)")
1282
+ parser.add_argument("--arrow-size", type=float,
1283
+ help=f"arrow size as a percent of the mesh's bounding-box diagonal "
1284
+ f"({_ARROW_SIZE_MIN_PERCENT}-{_ARROW_SIZE_MAX_PERCENT}, default "
1285
+ f"{_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT})")
1286
+ parser.add_argument("--opacity", type=float, help="mesh opacity percent, 0-100 (default 100)")
1287
+ parser.add_argument("--overlay-mesh", action="store_true",
1288
+ help="draw mesh cell edges on top of the colored surface")
1289
+ parser.add_argument("--view-axis", choices=["X+", "X-", "Y+", "Y-", "Z+", "Z-", "ISO"],
1290
+ help="camera direction: look down +/-X/Y/Z, or ISO for a default "
1291
+ "isometric view. Not the same as --clip-axis.")
1292
+ parser.add_argument("--screenshot",
1293
+ help="render off-screen and save a PNG here instead of opening an "
1294
+ "interactive window, then exit - works with no display")
1126
1295
  args = parser.parse_args()
1127
1296
 
1297
+ if (args.clip_position is not None or args.clip_max) and not args.clip_axis:
1298
+ parser.error("--clip-position/--clip-max require --clip-axis")
1299
+ if args.clip_position is not None and args.clip_max:
1300
+ parser.error("--clip-position and --clip-max are mutually exclusive")
1301
+ if args.opacity is not None and not (0 <= args.opacity <= 100):
1302
+ parser.error("--opacity must be between 0 and 100")
1303
+ if args.arrow_size is not None and not (_ARROW_SIZE_MIN_PERCENT <= args.arrow_size <= _ARROW_SIZE_MAX_PERCENT):
1304
+ parser.error(f"--arrow-size must be between {_ARROW_SIZE_MIN_PERCENT} and {_ARROW_SIZE_MAX_PERCENT}")
1305
+ if args.log_range_db is not None and args.log_range_db <= 0:
1306
+ parser.error("--log-range-db must be positive")
1307
+
1128
1308
  file_paths = [args.file_path] if args.file_path else []
1129
1309
  if not file_paths and args.run_path:
1130
1310
  if args.source == _PALACE:
@@ -1150,7 +1330,101 @@ def main():
1150
1330
  if not file_paths:
1151
1331
  parser.error("no file_path given, and none could be resolved from --run-path")
1152
1332
 
1153
- window = FieldViewerWindow(_StandaloneMainWindow(), file_paths, args.source)
1333
+ def die(message):
1334
+ print(f"Error: {message}", file=sys.stderr)
1335
+ sys.exit(1)
1336
+
1337
+ window = FieldViewerWindow(_StandaloneMainWindow(), file_paths, args.source,
1338
+ off_screen=bool(args.screenshot))
1339
+ if window._full_mesh is None:
1340
+ die(window._load_error or "failed to load the field-result file")
1341
+
1342
+ if args.array:
1343
+ if args.array not in window._full_mesh.point_data:
1344
+ available = ", ".join(window._full_mesh.point_data.keys())
1345
+ die(f"--array {args.array!r} not found in this file. Available arrays: {available}")
1346
+ window.array_combo.setCurrentText(args.array)
1347
+ elif args.field:
1348
+ resolved = _resolve_field_shorthand(args.field, window._full_mesh, args.source)
1349
+ if resolved is None:
1350
+ die(f"--field {args.field!r} is not available for --source {args.source} in this "
1351
+ f"file (e.g. 's' has no Elmer-EM equivalent, 'temp' needs a temperature-named "
1352
+ f"array actually present)")
1353
+ window.array_combo.setCurrentText(resolved)
1354
+
1355
+ if args.log_scale or args.log_range_db is not None:
1356
+ window.log_scale_cb.setChecked(True)
1357
+ if args.log_range_db is not None:
1358
+ array_name = window.array_combo.currentText()
1359
+ result = _db_range_to_clim(window._full_mesh, array_name, args.log_range_db)
1360
+ if result is None:
1361
+ die(f"--log-range-db: array {array_name!r} has no positive values to scale from")
1362
+ floor, data_max = result
1363
+ window.clim_min_edit.setText(f"{floor:.6g}")
1364
+ window.clim_max_edit.setText(f"{data_max:.6g}")
1365
+ window._redraw()
1366
+
1367
+ if args.opacity is not None:
1368
+ window.opacity_slider.setValue(round(args.opacity))
1369
+ if args.overlay_mesh:
1370
+ window.show_edges_cb.setChecked(True)
1371
+ if args.arrows:
1372
+ window.show_vectors_cb.setChecked(True)
1373
+ if not window.show_vectors_cb.isEnabled():
1374
+ print("Warning: --arrows requested but the selected array is not a vector "
1375
+ "field; no arrows will be drawn.", file=sys.stderr)
1376
+ if args.arrow_size is not None:
1377
+ window.arrow_size_slider.setValue(round(args.arrow_size / _ARROW_SIZE_STEP_PERCENT))
1378
+
1379
+ if args.view_axis:
1380
+ if args.view_axis == "ISO":
1381
+ window.plotter.view_isometric()
1382
+ else:
1383
+ window._set_view(args.view_axis[0], 1 if args.view_axis[1] == "+" else -1)
1384
+
1385
+ if args.clip_axis:
1386
+ axis_radio = {"X": window.axis_radio_x, "Y": window.axis_radio_y,
1387
+ "Z": window.axis_radio_z}[args.clip_axis]
1388
+ axis_radio.setChecked(True)
1389
+ if args.clip_max:
1390
+ window._move_slider_to_max()
1391
+ elif args.clip_position is not None:
1392
+ window.clip_slider.setValue(
1393
+ window._position_um_to_slider_value(args.clip_axis, args.clip_position))
1394
+ window.clip_enabled_cb.setChecked(True)
1395
+
1396
+ if args.screenshot:
1397
+ # _ClipWorker's succeeded/failed signals only get delivered while the
1398
+ # event loop is pumped (see field_viewer.py's async-clip design) - and
1399
+ # merely checking _clip_thread.isRunning() isn't enough proof the
1400
+ # result was actually applied: on a small/fast mesh the background
1401
+ # thread can finish (isRunning() -> False) before this loop ever
1402
+ # calls processEvents() even once, leaving its queued succeeded
1403
+ # signal undelivered and _apply_display_mesh() never called for it
1404
+ # (confirmed - a first pass at this loop shipped a screenshot of the
1405
+ # unclipped mesh because of exactly this race). Call processEvents()
1406
+ # unconditionally every iteration, and only stop once the cache
1407
+ # actually reflects the currently-desired clip state (or clipping
1408
+ # isn't enabled at all).
1409
+ deadline = time.monotonic() + 300
1410
+ while True:
1411
+ app.processEvents()
1412
+ still_running = window._clip_thread is not None and window._clip_thread.isRunning()
1413
+ current_key = window._current_clip_key()
1414
+ settled = current_key is None or window._clipped_mesh_cache_key == current_key
1415
+ if not still_running and settled:
1416
+ break
1417
+ time.sleep(0.02)
1418
+ if time.monotonic() > deadline:
1419
+ print("Warning: timed out waiting for the clip plane to finish computing - "
1420
+ "the screenshot may not reflect the requested clip position.",
1421
+ file=sys.stderr)
1422
+ break
1423
+ window.plotter.screenshot(args.screenshot)
1424
+ print(f"Saved screenshot to {args.screenshot}")
1425
+ window.close()
1426
+ sys.exit(0)
1427
+
1154
1428
  window.show()
1155
1429
  sys.exit(app.exec())
1156
1430
 
@@ -0,0 +1,135 @@
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
+ """
20
+ gds_hierarchy_scan.py
21
+
22
+ Cheap, pure-gdspy GDS cell-hierarchy walkers that never call cell.flatten().
23
+ gds2palace's own gds_reader.read_gds() flattens the whole chosen cell (every
24
+ array/reference expanded into real, individual polygon objects) before doing
25
+ anything else - fine for actually building a model, but for a densely-arrayed
26
+ layout (fill patterns, via arrays) that flatten alone can take tens of
27
+ seconds, which is far too slow for something that only needs a yes/no or a
28
+ count. The functions here answer those questions by walking the
29
+ cell/reference tree directly instead, recursing into referenced cells
30
+ without expanding them, so cost scales with the (small) number of distinct
31
+ cells rather than the (potentially huge) number of instantiated copies.
32
+
33
+ No PySide6/setup_common/layout_preview dependency here - keeps this a leaf
34
+ module importable from both without any circular-import concern (setup_common.py
35
+ and layout_preview.py already need a deferred import between each other).
36
+ """
37
+
38
+ import gdspy
39
+
40
+
41
+ def open_gds_cell(gds_path, cellname):
42
+ """Open gds_path and resolve the target cell: cellname if given and
43
+ found, else the file's first top-level cell. Shared first step for
44
+ every walker below. Raises on a bad file or a library with no top-level
45
+ cell, same as gdspy itself would - callers already wrap their own call
46
+ sites in a broad try/except.
47
+ """
48
+ library = gdspy.GdsLibrary(infile=gds_path)
49
+ top_level = library.top_level()
50
+ if not top_level:
51
+ raise ValueError(f"no top-level cell in {gds_path}")
52
+ cell = library.cells.get(cellname, top_level[0])
53
+ return library, cell
54
+
55
+
56
+ def estimate_polygon_count(gds_path, cellname, layernumbers, purposelist):
57
+ """Fast pre-check of how many polygons a full read_gds()-based load
58
+ would end up processing, without flattening the GDS hierarchy. Walks
59
+ the cell/reference tree directly: a plain CellReference contributes its
60
+ referenced cell's own (memoized) count once, a CellArray contributes it
61
+ columns*rows times - the same arithmetic flatten() would otherwise do
62
+ by actually generating that many polygon objects. Returns None if the
63
+ file can't even be opened here (the real read_gds() call is expected to
64
+ surface that error properly instead).
65
+ """
66
+ try:
67
+ library, cell = open_gds_cell(gds_path, cellname)
68
+ except Exception:
69
+ return None
70
+
71
+ layernumbers = set(int(n) for n in layernumbers)
72
+ purposeset = set(int(p) for p in purposelist) if purposelist else None
73
+
74
+ memo = {}
75
+
76
+ def count(c):
77
+ if c.name in memo:
78
+ return memo[c.name]
79
+ total = 0
80
+ for polygonset in c.polygons:
81
+ for layer, datatype in zip(polygonset.layers, polygonset.datatypes):
82
+ if int(layer) in layernumbers and (purposeset is None or int(datatype) in purposeset):
83
+ total += 1
84
+ for ref in c.references:
85
+ ref_cell = ref.ref_cell
86
+ if isinstance(ref_cell, str):
87
+ ref_cell = library.cells.get(ref_cell)
88
+ if ref_cell is None:
89
+ continue
90
+ sub = count(ref_cell)
91
+ total += sub * ref.columns * ref.rows if isinstance(ref, gdspy.CellArray) else sub
92
+ memo[c.name] = total
93
+ return total
94
+
95
+ return count(cell)
96
+
97
+
98
+ def layers_present_in_range(gds_path, cellname, layer_min, layer_max, purposelist):
99
+ """Set of GDS layer numbers in [layer_min, layer_max] with at least one
100
+ polygon whose datatype is in purposelist (any datatype if purposelist is
101
+ falsy), anywhere in the cell hierarchy - without flattening. Recursive,
102
+ memoized-per-cell-name, set-union based: unlike estimate_polygon_count,
103
+ presence needs no CellArray columns*rows multiplication (N copies of a
104
+ present layer is still just "present"), so CellReference and CellArray
105
+ are treated identically here. Raises on open/resolve failure, same as
106
+ open_gds_cell(); callers wrap in their own try/except.
107
+ """
108
+ library, cell = open_gds_cell(gds_path, cellname)
109
+ wanted = set(range(layer_min, layer_max + 1))
110
+ purposeset = set(int(p) for p in purposelist) if purposelist else None
111
+
112
+ memo = {}
113
+
114
+ def scan(c):
115
+ if c.name in memo:
116
+ return memo[c.name]
117
+ found = set()
118
+ for polygonset in c.polygons:
119
+ for layer, datatype in zip(polygonset.layers, polygonset.datatypes):
120
+ layer = int(layer)
121
+ if layer in wanted and (purposeset is None or int(datatype) in purposeset):
122
+ found.add(layer)
123
+ for ref in c.references:
124
+ if found >= wanted:
125
+ break # already covers every requested layer - nothing left to gain
126
+ ref_cell = ref.ref_cell
127
+ if isinstance(ref_cell, str):
128
+ ref_cell = library.cells.get(ref_cell)
129
+ if ref_cell is None:
130
+ continue
131
+ found |= scan(ref_cell)
132
+ memo[c.name] = found
133
+ return found
134
+
135
+ return scan(cell)
@@ -44,6 +44,11 @@ from PySide6.QtCore import Qt, QPointF, QLineF, Signal
44
44
 
45
45
  from gds2palace import gds_reader, stackup_reader
46
46
 
47
+ if __package__ in (None, ""):
48
+ import gds_hierarchy_scan
49
+ else:
50
+ from . import gds_hierarchy_scan
51
+
47
52
  DEFAULT_LAYER_COLOR = "#a0a0a0" # stackup_material.color has no default (None) if XML omits Color=
48
53
  PEC_LAYER_COLOR = "#b4dcff" # matches setup_common.PEC_MATERIAL_COLOR's soft blue
49
54
  PORT_OUTLINE_COLOR = "#ff33ff"
@@ -61,6 +66,23 @@ MARKER_OUTLINE_WIDTH = 3
61
66
  # initial layer-opacity slider position
62
67
  DEFAULT_LAYER_OPACITY_PERCENT = 70
63
68
 
69
+ # above this many polygons on the selected layers/purpose, refresh() warns
70
+ # before actually reading the GDS - gds_reader.read_gds() flattens the whole
71
+ # cell hierarchy (expanding every array/reference into real geometry) and
72
+ # then this window builds one QGraphicsPolygonItem per polygon, either of
73
+ # which can take a very long time (or exhaust memory) for a layout with
74
+ # dense fill/via arrays, long before the window becomes responsive again
75
+ POLYGON_COUNT_WARNING_THRESHOLD = 50000
76
+
77
+
78
+ def _estimate_polygon_count(gds_path, cellname, layernumbers, purposelist):
79
+ """Thin wrapper around gds_hierarchy_scan.estimate_polygon_count() - see
80
+ that function's docstring for the "walk the hierarchy, don't flatten it"
81
+ approach. Kept as a module-level name here since refresh() below already
82
+ calls it that way.
83
+ """
84
+ return gds_hierarchy_scan.estimate_polygon_count(gds_path, cellname, layernumbers, purposelist)
85
+
64
86
  # cross-window highlight (see set_highlighted_layer()): a layer selected in the
65
87
  # Stackup Preview/Editor gets this outline + hatch fill, above every regular
66
88
  # layer but *below* every port/source/boundary marker (those must stay
@@ -591,6 +613,26 @@ class LayoutPreviewWindow(QDialog):
591
613
 
592
614
  gds_path_to_read = self._gds_override_path or saved_values["GdsFile"]
593
615
 
616
+ # cheap (no flattening) pre-check before committing to the potentially
617
+ # very slow read_gds() + scene-building work below - see
618
+ # _estimate_polygon_count()'s docstring for why this doesn't just
619
+ # flatten and count directly
620
+ estimated_count = _estimate_polygon_count(
621
+ gds_path_to_read, saved_values["cellname"], layernumbers, saved_values["purpose"])
622
+ if estimated_count is not None and estimated_count > POLYGON_COUNT_WARNING_THRESHOLD:
623
+ reply = QMessageBox.warning(
624
+ self, "Large layout",
625
+ f"This layout has an estimated {estimated_count:,} polygons on the "
626
+ "selected layers/purpose. Loading it here can make this window very "
627
+ "slow or unresponsive for a long time.\n\nContinue anyway?",
628
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
629
+ if reply != QMessageBox.Yes:
630
+ self._info_base_text = (
631
+ f"Not loaded: estimated {estimated_count:,} polygons "
632
+ f"exceeds the {POLYGON_COUNT_WARNING_THRESHOLD:,}-polygon warning threshold")
633
+ self._update_info_label()
634
+ return
635
+
594
636
  # reading/preprocessing the GDS and building every polygon item below
595
637
  # can take a while for a large layout - show a wait cursor for the
596
638
  # whole stretch, not just the read_gds() call, since building the
@@ -57,6 +57,11 @@ from PySide6.QtCore import Qt, QRegularExpression, QProcess, QRect, QRectF, QTim
57
57
  import gds2palace
58
58
  from gds2palace import *
59
59
 
60
+ if __package__ in (None, ""):
61
+ import gds_hierarchy_scan
62
+ else:
63
+ from . import gds_hierarchy_scan
64
+
60
65
  # ------------------------------------------------------------------
61
66
  # gds2palace feature-compatibility detection: an older gds2palace (e.g. a stale
62
67
  # bundled copy, or an outdated pip install) may be missing modules/functions this
@@ -513,6 +518,32 @@ def update_missing_layer_column(table, source_col, comment_col, gds_layers_prese
513
518
  table.setItem(row, comment_col, QTableWidgetItem(comment))
514
519
 
515
520
 
521
+ def _derived_layer_range_is_safe(metals_list, layer_min, layer_max):
522
+ """True only if MainWindowBase.get_gds_layers_in_range()'s fast,
523
+ non-flattening hierarchy scan cannot possibly miss a layer that "exists"
524
+ solely via boolean derivation from other real layers - i.e. no stackup
525
+ DerivedLayer's output layer number falls inside [layer_min, layer_max].
526
+ A derived layer's polygons are computed by gds_reader.resolve_derived_layers()
527
+ from other real layers and never exist as literal geometry in the raw
528
+ GDS, so a hierarchy walk that only looks at actual polygons would wrongly
529
+ report one "absent" if it lands in the queried range.
530
+
531
+ Deliberately conservative: any failure to introspect derived_layers
532
+ safely returns False (use the slow/exact read_gds()-based path), never
533
+ the reverse. In the common case - no <DerivedLayers> section in the
534
+ stackup XML at all - metals_list.derived_layers is None and this returns
535
+ True immediately.
536
+ """
537
+ derived_layers = getattr(metals_list, "derived_layers", None)
538
+ if derived_layers is None:
539
+ return True
540
+ try:
541
+ layernums = derived_layers.getlayernumbers()
542
+ except Exception:
543
+ return False
544
+ return not any(layer_min <= n <= layer_max for n in layernums)
545
+
546
+
516
547
  # ----------------------------------------
517
548
 
518
549
  class FileDropLineEdit(QLineEdit):
@@ -3010,17 +3041,32 @@ class MainWindowBase(QMainWindow):
3010
3041
  def get_gds_layers_in_range(self, layer_min, layer_max):
3011
3042
  """Return the set of GDS layer numbers in [layer_min, layer_max] that
3012
3043
  have at least one polygon on a datatype in the current purpose filter
3013
- - read the same way gds2palace's own reader would (same cellname/
3014
- purpose/preprocess), so "present" here means the same thing it would
3015
- during a real model build. Returns an empty set if the GDS file or
3016
- stackup isn't loaded/valid, rather than raising - this is only used
3017
- for Ports/Thermal tab UI hints (next-available-layer suggestion,
3044
+ - "present" here means the same thing it would during a real model
3045
+ build (same cellname/purpose). Returns an empty set if the GDS file
3046
+ or stackup isn't loaded/valid, rather than raising - this is only
3047
+ used for Ports/Thermal tab UI hints (next-available-layer suggestion,
3018
3048
  "(missing in layout)" annotations), never anything simulation-critical.
3019
3049
 
3020
3050
  Reads the Input Files tab's *live* widgets rather than saved_values,
3021
3051
  which only gets populated once that tab has been left at least once -
3022
3052
  a Ports/Thermal tab reached before that would otherwise see an empty
3023
3053
  GdsFile and silently find nothing.
3054
+
3055
+ Answered via gds_hierarchy_scan.layers_present_in_range() (walks the
3056
+ cell hierarchy directly, no flatten) rather than gds2palace's own
3057
+ gds_reader.read_gds(), which unconditionally flattens the whole
3058
+ chosen cell first - fine for building a real model, but this is
3059
+ called automatically and repeatedly just from browsing to a GDS file
3060
+ or switching to the Ports/Thermal tab (see refresh_source_layer_hints()/
3061
+ showEvent() in setupEM.py/setupThermal.py), so a densely-arrayed
3062
+ layout (fill patterns, via arrays) made that flatten cost tens of
3063
+ seconds per call, multiple times, before the user did anything else.
3064
+ Falls back to the original read_gds()-based computation only when a
3065
+ stackup "derived layer" (util_stackup_reader.derived_layer - a
3066
+ synthetic layer computed via boolean ops on other real layers, whose
3067
+ polygons don't exist as literal raw-GDS geometry) could have its
3068
+ output layer number inside [layer_min, layer_max] - the fast
3069
+ hierarchy walk can't see those, so it isn't safe to use there.
3024
3070
  """
3025
3071
  gdsfile = self.file_tab.gds_file_edit.text()
3026
3072
  if not os.path.isfile(gdsfile) or self.metals_list is None:
@@ -3031,6 +3077,17 @@ class MainWindowBase(QMainWindow):
3031
3077
  purposelist = ast.literal_eval('[' + purpose_text + ']') if purpose_text else [0]
3032
3078
  except Exception:
3033
3079
  purposelist = [0]
3080
+
3081
+ if _derived_layer_range_is_safe(self.metals_list, layer_min, layer_max):
3082
+ try:
3083
+ return gds_hierarchy_scan.layers_present_in_range(
3084
+ gdsfile, cellname, layer_min, layer_max, purposelist)
3085
+ except Exception:
3086
+ return set()
3087
+
3088
+ # slow/exact path: only reached when a derived layer's synthetic
3089
+ # output number could fall inside [layer_min, layer_max] - unchanged
3090
+ # from the original implementation
3034
3091
  preprocess = self.file_tab.preprocess_gds_checkbox.isChecked()
3035
3092
  layernumbers = list(range(layer_min, layer_max + 1))
3036
3093
  captured_stdout = io.StringIO()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.7.2
3
+ Version: 0.7.3
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
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  src/setupEM/__init__.py
6
6
  src/setupEM/__main__.py
7
7
  src/setupEM/field_viewer.py
8
+ src/setupEM/gds_hierarchy_scan.py
8
9
  src/setupEM/layout_preview.py
9
10
  src/setupEM/momentum_import.py
10
11
  src/setupEM/palace_results.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes