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.
- {setupem-0.7.2/src/setupEM.egg-info → setupem-0.7.3}/PKG-INFO +1 -1
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/__init__.py +1 -1
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/field_viewer.py +305 -31
- setupem-0.7.3/src/setupEM/gds_hierarchy_scan.py +135 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/layout_preview.py +42 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setup_common.py +62 -5
- {setupem-0.7.2 → setupem-0.7.3/src/setupEM.egg-info}/PKG-INFO +1 -1
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/SOURCES.txt +1 -0
- {setupem-0.7.2 → setupem-0.7.3}/LICENSE +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/README.md +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/README_pypi.md +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/pyproject.toml +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/setup.cfg +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/__main__.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_100um.xml +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_200um.xml +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/data/SG13G2_nosub.xml +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/momentum_import.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/palace_results.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/result_viewer.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setupEM.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/setupThermal.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/simplify_gds.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/stackupEditor.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/stackup_writer.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM/thermal_results.py +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/dependency_links.txt +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/entry_points.txt +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/requires.txt +0 -0
- {setupem-0.7.2 → setupem-0.7.3}/src/setupEM.egg-info/top_level.txt +0 -0
|
@@ -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.
|
|
147
|
-
# of
|
|
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.
|
|
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
|
-
|
|
507
|
-
|
|
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
|
-
|
|
511
|
-
|
|
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
|
-
|
|
515
|
-
field_layout.addLayout(
|
|
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.
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
3014
|
-
purpose
|
|
3015
|
-
|
|
3016
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|