setupEM 0.8.1__tar.gz → 0.8.2__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.8.1/src/setupEM.egg-info → setupem-0.8.2}/PKG-INFO +1 -1
- {setupem-0.8.1 → setupem-0.8.2}/README.md +5 -1
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/__init__.py +1 -1
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/field_viewer.py +270 -78
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/palace_results.py +13 -0
- {setupem-0.8.1 → setupem-0.8.2/src/setupEM.egg-info}/PKG-INFO +1 -1
- {setupem-0.8.1 → setupem-0.8.2}/LICENSE +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/README_pypi.md +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/pyproject.toml +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/setup.cfg +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/__main__.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_100um.xml +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_200um.xml +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_nosub.xml +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/gds_hierarchy_scan.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/layout_preview.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/momentum_import.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/result_viewer.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setupEM.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setupThermal.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setup_common.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/simplify_gds.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/stackupEditor.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/stackup_writer.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/thermal_results.py +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/SOURCES.txt +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/dependency_links.txt +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/entry_points.txt +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/requires.txt +0 -0
- {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/top_level.txt +0 -0
|
@@ -6,6 +6,10 @@ Reserved PEC/AIR stackup materials, Layout Preview, Results viewer, Model Fit, b
|
|
|
6
6
|
|
|
7
7
|
See [CHANGES.md](doc/CHANGES.md) for details.
|
|
8
8
|
|
|
9
|
+
## Video Tutorial
|
|
10
|
+
|
|
11
|
+
https://www.youtube.com/playlist?list=PLQ6NbZzeLAVU
|
|
12
|
+
|
|
9
13
|
## SetupEM
|
|
10
14
|
|
|
11
15
|
[gds2palace](https://github.com/VolkerMuehlhaus/gds2palace_ihp_sg13g2) enables an **RFIC FEM simulation** workflow where GDSII layout files are simulated using the [Palace FEM solver by AWS](https://awslabs.github.io/palace/stable/). setupEM provides a Python-based **graphical user interface** to configure and run gds2palace, instead of creating the simulation model code manually, and also start simulation in Palace.
|
|
@@ -320,4 +324,4 @@ If you then start KLayout using this script, a new menu item is available: Tools
|
|
|
320
324
|
|
|
321
325
|
# License
|
|
322
326
|
|
|
323
|
-
This project is licensed under the GNU General Public License v3.0 or later (GPL-3.0-or-later) - see [LICENSE](./LICENSE) for the full text.
|
|
327
|
+
This project is licensed under the GNU General Public License v3.0 or later (GPL-3.0-or-later) - see [LICENSE](./LICENSE) for the full text.
|
|
@@ -63,17 +63,17 @@ from PySide6.QtWidgets import (
|
|
|
63
63
|
QLabel, QPushButton, QRadioButton, QButtonGroup, QCheckBox,
|
|
64
64
|
QSlider, QComboBox, QLineEdit, QStyleFactory,
|
|
65
65
|
)
|
|
66
|
-
from PySide6.QtCore import Qt, QThread, Signal
|
|
66
|
+
from PySide6.QtCore import Qt, QThread, QTimer, Signal
|
|
67
67
|
from PySide6.QtGui import QShortcut, QKeySequence
|
|
68
68
|
|
|
69
69
|
# __package__ is None/"" when this file is run directly rather than imported as part
|
|
70
70
|
# of the setupEM package, so relative import fails - same dual-mode pattern used
|
|
71
71
|
# throughout setupEM.py/setup_common.py/result_viewer.py for sibling imports.
|
|
72
72
|
if __package__ in (None, ""):
|
|
73
|
-
from palace_results import find_paraview_files
|
|
73
|
+
from palace_results import find_paraview_files, is_amr_iteration_path
|
|
74
74
|
from thermal_results import find_thermal_paraview_file
|
|
75
75
|
else:
|
|
76
|
-
from .palace_results import find_paraview_files
|
|
76
|
+
from .palace_results import find_paraview_files, is_amr_iteration_path
|
|
77
77
|
from .thermal_results import find_thermal_paraview_file
|
|
78
78
|
|
|
79
79
|
|
|
@@ -266,33 +266,42 @@ def _exact_clip_by_axis(mesh, axis, position, sign):
|
|
|
266
266
|
|
|
267
267
|
class _ClipWorker(QThread):
|
|
268
268
|
"""Runs _exact_clip_by_axis() on a background thread - see that function's
|
|
269
|
-
docstring for why this can't just run inline in _redraw().
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
269
|
+
docstring for why this can't just run inline in _redraw(). Always given a
|
|
270
|
+
private, deep-copied mesh (see FieldViewerWindow._full_mesh_for_clip) that
|
|
271
|
+
only this worker ever touches - never the live self._full_mesh the GUI
|
|
272
|
+
thread renders directly and mutates (add_mesh()'s active-scalars
|
|
273
|
+
assignment, _add_vector_glyphs()'s point-data add/delete) - so there is no
|
|
274
|
+
object either thread can race on, regardless of timing or mesh size.
|
|
275
|
+
|
|
276
|
+
Emits exactly one of succeeded/failed, tagged with the (axis, position,
|
|
277
|
+
sign, generation) it was computed for, so a result that's no longer
|
|
278
|
+
relevant (the user has since moved to a different axis/position/sign, or
|
|
279
|
+
loaded a different file entirely - see FieldViewerWindow._mesh_generation)
|
|
280
|
+
can be told apart from one that's still current - see
|
|
281
|
+
FieldViewerWindow._on_clip_succeeded(), which compares this against the
|
|
282
|
+
currently-desired key rather than trusting an opaque "is this the latest
|
|
283
|
+
request" counter, so a result stays usable even if something unrelated
|
|
284
|
+
(opacity, color scale, ...) redrew in the meantime while this was still
|
|
285
|
+
computing.
|
|
278
286
|
"""
|
|
279
|
-
succeeded = Signal(object, str, float, int) # (clipped_mesh, axis, position, sign)
|
|
280
|
-
failed = Signal(str, str, float, int) # (error_message, axis, position, sign)
|
|
287
|
+
succeeded = Signal(object, str, float, int, int) # (clipped_mesh, axis, position, sign, generation)
|
|
288
|
+
failed = Signal(str, str, float, int, int) # (error_message, axis, position, sign, generation)
|
|
281
289
|
|
|
282
|
-
def __init__(self, mesh, axis, position, sign):
|
|
290
|
+
def __init__(self, mesh, axis, position, sign, generation):
|
|
283
291
|
super().__init__()
|
|
284
292
|
self._mesh = mesh
|
|
285
293
|
self._axis = axis
|
|
286
294
|
self._position = position
|
|
287
295
|
self._sign = sign
|
|
296
|
+
self._generation = generation
|
|
288
297
|
|
|
289
298
|
def run(self):
|
|
290
299
|
try:
|
|
291
300
|
result = _exact_clip_by_axis(self._mesh, self._axis, self._position, self._sign)
|
|
292
301
|
except Exception as exc:
|
|
293
|
-
self.failed.emit(str(exc), self._axis, self._position, self._sign)
|
|
302
|
+
self.failed.emit(str(exc), self._axis, self._position, self._sign, self._generation)
|
|
294
303
|
return
|
|
295
|
-
self.succeeded.emit(result, self._axis, self._position, self._sign)
|
|
304
|
+
self.succeeded.emit(result, self._axis, self._position, self._sign, self._generation)
|
|
296
305
|
|
|
297
306
|
|
|
298
307
|
def _array_magnitudes(values):
|
|
@@ -375,16 +384,28 @@ class FieldViewerWindow(QDialog):
|
|
|
375
384
|
"""Own top-level window (no Qt parent, WA_DeleteOnClose - same lifecycle as
|
|
376
385
|
ResultViewerWindow), showing one field-result file - picked from file_paths,
|
|
377
386
|
which may hold more than one equally-valid result (e.g. Palace can write both
|
|
378
|
-
a main "driven" field dump and a separate "driven_boundary" one
|
|
379
|
-
inherently the "right" one to
|
|
380
|
-
|
|
387
|
+
a main "driven" field dump and a separate "driven_boundary" one, per
|
|
388
|
+
excitation, per AMR iteration; neither is inherently the "right" one to
|
|
389
|
+
default to, so the user picks - see _build_file_labels() for how each one
|
|
390
|
+
is labeled) - with a single axis-aligned clip plane and a field/array
|
|
391
|
+
picker."""
|
|
381
392
|
|
|
382
393
|
def __init__(self, MainWindow, file_paths, source, off_screen=False):
|
|
383
394
|
super().__init__()
|
|
384
395
|
self.setAttribute(Qt.WA_DeleteOnClose)
|
|
385
396
|
self.MainWindow = MainWindow
|
|
386
397
|
self.file_paths = list(file_paths)
|
|
387
|
-
|
|
398
|
+
# AMR runs write a copy of every field-dump file under each iteration<N>
|
|
399
|
+
# subfolder in addition to the final, most-refined pass, multiplying this
|
|
400
|
+
# list by the iteration count - default to hiding those (they exist for
|
|
401
|
+
# convergence tracking, not usually 3D inspection); the "Include AMR
|
|
402
|
+
# iterations" checkbox in _build_ui() reveals the rest on request. Falls
|
|
403
|
+
# back to the full list if every candidate is (unexpectedly) flagged as
|
|
404
|
+
# an iteration path, rather than ending up with nothing to show.
|
|
405
|
+
self._final_paths = [p for p in self.file_paths if not is_amr_iteration_path(p)] or self.file_paths
|
|
406
|
+
self._visible_paths = self._final_paths
|
|
407
|
+
self.file_path = self._visible_paths[0]
|
|
408
|
+
self._file_labels = self._build_file_labels(self.file_paths)
|
|
388
409
|
self.source = source
|
|
389
410
|
# CLI-only (see main()'s --screenshot): render off-screen instead of
|
|
390
411
|
# in a real, visible window - lets field_viewer.py run headless, e.g.
|
|
@@ -427,21 +448,46 @@ class FieldViewerWindow(QDialog):
|
|
|
427
448
|
self._pending_clip_request = None
|
|
428
449
|
self._clip_busy_cursor_active = False
|
|
429
450
|
self._active_clip_key = None
|
|
451
|
+
# Private, deep-copied mesh handed to every _ClipWorker - never
|
|
452
|
+
# self._full_mesh itself. Ownership contract: once _start_clip_thread()
|
|
453
|
+
# hands this to a worker and starts it, the GUI thread must never read
|
|
454
|
+
# or write it again until _load_mesh() resets it to None for a new
|
|
455
|
+
# mesh. This is what actually makes the GUI thread's direct touches of
|
|
456
|
+
# self._full_mesh (add_mesh()'s active-scalars assignment,
|
|
457
|
+
# _add_vector_glyphs()'s point-data add/delete) safe regardless of
|
|
458
|
+
# timing: two distinct objects can never race on each other's
|
|
459
|
+
# internals, no matter how the two threads interleave. Created lazily
|
|
460
|
+
# (on first use, not eagerly on load) so a mesh clipping is never
|
|
461
|
+
# enabled for doesn't pay double memory for nothing.
|
|
462
|
+
self._full_mesh_for_clip = None
|
|
463
|
+
# Bumped by every _load_mesh() call (see there) and folded into every
|
|
464
|
+
# clip cache/request key below, so a clip result computed for a
|
|
465
|
+
# previous file/load can never be mistaken for one belonging to the
|
|
466
|
+
# currently displayed mesh - e.g. if the Result File combo is changed
|
|
467
|
+
# while a clip is still in flight for the old file.
|
|
468
|
+
self._mesh_generation = 0
|
|
430
469
|
# Cache of the last background-clip result, keyed by the (axis,
|
|
431
|
-
# position, sign) it was computed for. Most redraw
|
|
432
|
-
# log scale, clim, mesh overlay, array selection,
|
|
433
|
-
# change the clip geometry at all - only
|
|
434
|
-
# reusing this avoids kicking
|
|
435
|
-
# for a purely cosmetic change.
|
|
436
|
-
#
|
|
437
|
-
# _redraw() calls) was
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
#
|
|
441
|
-
#
|
|
442
|
-
#
|
|
470
|
+
# position, sign, generation) it was computed for. Most redraw
|
|
471
|
+
# triggers (opacity, log scale, clim, mesh overlay, array selection,
|
|
472
|
+
# vector arrows) don't change the clip geometry at all - only
|
|
473
|
+
# axis/position/sign/generation do - so reusing this avoids kicking
|
|
474
|
+
# off another expensive background clip for a purely cosmetic change.
|
|
475
|
+
# Also closes a real crash: on Linux and Windows, dragging the
|
|
476
|
+
# opacity slider (which used to fire many rapid _redraw() calls) was
|
|
477
|
+
# starting/finishing background clip threads in quick succession,
|
|
478
|
+
# occasionally destroying a _ClipWorker just before Qt considered it
|
|
479
|
+
# fully stopped ("QThread: Destroyed while thread is still running")
|
|
480
|
+
# - this cache means opacity changes no longer touch the clip thread
|
|
481
|
+
# machinery at all once one clip result exists for the current
|
|
482
|
+
# axis/position/generation. See _redraw()/_on_clip_succeeded().
|
|
443
483
|
self._clipped_mesh_cache = None
|
|
444
484
|
self._clipped_mesh_cache_key = None
|
|
485
|
+
# See _schedule_redraw()/_perform_scheduled_redraw(): coalesces any
|
|
486
|
+
# number of redraw-triggering signals firing before the event loop
|
|
487
|
+
# next turns into a single _redraw() call, so rapid-fire UI changes on
|
|
488
|
+
# a large/complex mesh never render more than the final requested
|
|
489
|
+
# state.
|
|
490
|
+
self._redraw_scheduled = False
|
|
445
491
|
|
|
446
492
|
self._build_ui()
|
|
447
493
|
self._load_mesh()
|
|
@@ -483,20 +529,34 @@ class FieldViewerWindow(QDialog):
|
|
|
483
529
|
controls_layout = QHBoxLayout()
|
|
484
530
|
|
|
485
531
|
# Only shown when there's more than one candidate result file (e.g. Palace's
|
|
486
|
-
# main "driven" field dump vs. its separate "driven_boundary" one
|
|
487
|
-
#
|
|
532
|
+
# main "driven" field dump vs. its separate "driven_boundary" one, per
|
|
533
|
+
# excitation, per AMR iteration - both/all equally valid, neither an
|
|
534
|
+
# inherently better default) - see __init__/_build_file_labels().
|
|
488
535
|
if len(self.file_paths) > 1:
|
|
489
536
|
file_group = QGroupBox("Result File")
|
|
490
537
|
file_layout = QVBoxLayout()
|
|
491
538
|
self.file_combo = QComboBox()
|
|
492
|
-
self.file_combo.addItems([self.
|
|
539
|
+
self.file_combo.addItems([self._file_labels[p] for p in self._visible_paths])
|
|
493
540
|
self.file_combo.currentIndexChanged.connect(self._on_file_changed)
|
|
494
541
|
file_layout.addWidget(self.file_combo)
|
|
542
|
+
|
|
543
|
+
# Only shown when AMR actually produced iteration<N> copies to hide -
|
|
544
|
+
# see __init__'s self._final_paths/self._visible_paths.
|
|
545
|
+
hidden_count = len(self.file_paths) - len(self._final_paths)
|
|
546
|
+
if hidden_count > 0:
|
|
547
|
+
self.include_iterations_cb = QCheckBox(f"Include AMR iterations ({hidden_count} more)")
|
|
548
|
+
self.include_iterations_cb.setChecked(False)
|
|
549
|
+
self.include_iterations_cb.toggled.connect(self._on_include_iterations_toggled)
|
|
550
|
+
file_layout.addWidget(self.include_iterations_cb)
|
|
551
|
+
else:
|
|
552
|
+
self.include_iterations_cb = None
|
|
553
|
+
|
|
495
554
|
file_layout.addStretch()
|
|
496
555
|
file_group.setLayout(file_layout)
|
|
497
556
|
controls_layout.addWidget(file_group, 1)
|
|
498
557
|
else:
|
|
499
558
|
self.file_combo = None
|
|
559
|
+
self.include_iterations_cb = None
|
|
500
560
|
|
|
501
561
|
# Clip Plane: purely "where/whether to cut" - rendering options that
|
|
502
562
|
# apply regardless of clipping (opacity, mesh overlay) live in their
|
|
@@ -540,7 +600,8 @@ class FieldViewerWindow(QDialog):
|
|
|
540
600
|
self.clip_slider = QSlider(Qt.Horizontal)
|
|
541
601
|
self.clip_slider.setRange(0, _SLIDER_STEPS)
|
|
542
602
|
self.clip_slider.setValue(_SLIDER_STEPS // 2)
|
|
543
|
-
self.clip_slider.valueChanged.connect(self.
|
|
603
|
+
self.clip_slider.valueChanged.connect(self._on_clip_slider_changed)
|
|
604
|
+
self.clip_slider.sliderReleased.connect(self._schedule_redraw)
|
|
544
605
|
clip_layout.addWidget(self.clip_slider)
|
|
545
606
|
|
|
546
607
|
clip_layout.addStretch()
|
|
@@ -562,6 +623,7 @@ class FieldViewerWindow(QDialog):
|
|
|
562
623
|
self.opacity_slider.setRange(0, 100)
|
|
563
624
|
self.opacity_slider.setValue(100)
|
|
564
625
|
self.opacity_slider.valueChanged.connect(self._on_opacity_changed)
|
|
626
|
+
self.opacity_slider.sliderReleased.connect(self._schedule_redraw)
|
|
565
627
|
display_layout.addWidget(self.opacity_slider)
|
|
566
628
|
|
|
567
629
|
self.arrow_size_label = QLabel(f"Arrow size: {_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT}%")
|
|
@@ -573,6 +635,7 @@ class FieldViewerWindow(QDialog):
|
|
|
573
635
|
self.arrow_size_slider.setValue(round(_VECTOR_ARROW_TARGET_FRACTION_PERCENT_DEFAULT / _ARROW_SIZE_STEP_PERCENT))
|
|
574
636
|
self.arrow_size_slider.setEnabled(False)
|
|
575
637
|
self.arrow_size_slider.valueChanged.connect(self._on_arrow_size_changed)
|
|
638
|
+
self.arrow_size_slider.sliderReleased.connect(self._schedule_redraw)
|
|
576
639
|
display_layout.addWidget(self.arrow_size_slider)
|
|
577
640
|
|
|
578
641
|
# Only meaningful (and enabled) when the selected Field array is itself
|
|
@@ -696,15 +759,67 @@ class FieldViewerWindow(QDialog):
|
|
|
696
759
|
# ---------- Result file picker ----------
|
|
697
760
|
|
|
698
761
|
@staticmethod
|
|
699
|
-
def
|
|
700
|
-
"""
|
|
701
|
-
|
|
702
|
-
|
|
762
|
+
def _build_file_labels(paths):
|
|
763
|
+
"""{path: display_label} for every candidate result file, unique and as
|
|
764
|
+
short as possible. Palace's own field-dump folder nesting (driven vs.
|
|
765
|
+
driven_boundary, per excitation, and - with AMR - per iteration on top
|
|
766
|
+
of either) can go more than one directory deep, so labeling from just
|
|
767
|
+
the immediate parent folder can collapse unrelated files into
|
|
768
|
+
identical-looking entries (e.g. several iterations' own
|
|
769
|
+
"excitation_1/excitation_1.pvd"). Building each label from the path
|
|
770
|
+
relative to the common ancestor of all candidates instead keeps
|
|
771
|
+
whatever higher-level folder (e.g. "iteration3") is needed to tell
|
|
772
|
+
them apart.
|
|
773
|
+
|
|
774
|
+
A trailing "<dir>/<dir-name>.<ext>" is then collapsed to just
|
|
775
|
+
"<dir-name>.<ext>", since Palace's own dump files are consistently
|
|
776
|
+
named after their containing folder - this actually shortens the
|
|
777
|
+
common single-level case ("driven/driven.pvd" -> "driven.pvd") while
|
|
778
|
+
losing no information.
|
|
779
|
+
|
|
780
|
+
When the candidates mix Palace's volume ("driven"-style) and
|
|
781
|
+
boundary/surface ("driven_boundary"-style) dumps, each label also gets
|
|
782
|
+
a short emoji prefix (matching this codebase's existing emoji-as-icon
|
|
783
|
+
convention, e.g. the "View fields (...)..." button) - skipped when
|
|
784
|
+
every candidate is the same type, since there's then nothing to flag.
|
|
785
|
+
"""
|
|
786
|
+
if len(paths) == 1:
|
|
787
|
+
return {paths[0]: os.path.basename(paths[0])}
|
|
788
|
+
|
|
789
|
+
root = os.path.commonpath(paths)
|
|
790
|
+
labels = {}
|
|
791
|
+
for path in paths:
|
|
792
|
+
rel = os.path.relpath(path, root).replace(os.sep, "/")
|
|
793
|
+
if "/" in rel:
|
|
794
|
+
dir_part, filename = rel.rsplit("/", 1)
|
|
795
|
+
stem = os.path.splitext(filename)[0]
|
|
796
|
+
last_dir = dir_part.rsplit("/", 1)[-1]
|
|
797
|
+
if last_dir == stem:
|
|
798
|
+
parent = dir_part.rsplit("/", 1)[0] if "/" in dir_part else ""
|
|
799
|
+
rel = f"{parent}/{filename}" if parent else filename
|
|
800
|
+
labels[path] = rel
|
|
801
|
+
|
|
802
|
+
is_boundary = {path: "boundary" in path.lower() for path in paths}
|
|
803
|
+
if len(set(is_boundary.values())) > 1:
|
|
804
|
+
for path in paths:
|
|
805
|
+
icon = "\U0001F532" if is_boundary[path] else "\U0001F9CA"
|
|
806
|
+
labels[path] = f"{icon} {labels[path]}"
|
|
807
|
+
|
|
808
|
+
return labels
|
|
703
809
|
|
|
704
810
|
def _on_file_changed(self, index):
|
|
705
|
-
if index < 0 or index >= len(self.
|
|
811
|
+
if index < 0 or index >= len(self._visible_paths):
|
|
812
|
+
return
|
|
813
|
+
self._switch_to_file(self._visible_paths[index])
|
|
814
|
+
|
|
815
|
+
def _switch_to_file(self, path):
|
|
816
|
+
"""Load `path` as the current result file, if it isn't already - shared by
|
|
817
|
+
_on_file_changed() (combo selection) and _on_include_iterations_toggled()
|
|
818
|
+
(which may need to fall back to a different file once AMR iterations are
|
|
819
|
+
hidden again)."""
|
|
820
|
+
if path == self.file_path:
|
|
706
821
|
return
|
|
707
|
-
self.file_path =
|
|
822
|
+
self.file_path = path
|
|
708
823
|
self._load_error = None
|
|
709
824
|
self.warning_label.setText("")
|
|
710
825
|
# A different result file can have entirely different geometry/bounds
|
|
@@ -715,15 +830,36 @@ class FieldViewerWindow(QDialog):
|
|
|
715
830
|
self._load_mesh()
|
|
716
831
|
self._on_axis_changed() # resets the clip slider for the new mesh's bounds, redraws
|
|
717
832
|
|
|
833
|
+
def _on_include_iterations_toggled(self, checked):
|
|
834
|
+
"""Swap the Result File combo between the default final-pass-only list and
|
|
835
|
+
the full list including AMR's per-iteration copies - see __init__'s
|
|
836
|
+
self._final_paths/self._visible_paths and is_amr_iteration_path()."""
|
|
837
|
+
self._visible_paths = self.file_paths if checked else self._final_paths
|
|
838
|
+
target_path = self.file_path if self.file_path in self._visible_paths else self._visible_paths[0]
|
|
839
|
+
self.file_combo.blockSignals(True)
|
|
840
|
+
self.file_combo.clear()
|
|
841
|
+
self.file_combo.addItems([self._file_labels[p] for p in self._visible_paths])
|
|
842
|
+
self.file_combo.setCurrentIndex(self._visible_paths.index(target_path))
|
|
843
|
+
self.file_combo.blockSignals(False)
|
|
844
|
+
self._switch_to_file(target_path)
|
|
845
|
+
|
|
718
846
|
# ---------- Mesh loading ----------
|
|
719
847
|
|
|
720
848
|
def _load_mesh(self):
|
|
721
|
-
self.setWindowTitle(f"Field Viewer - {self.
|
|
722
|
-
#
|
|
723
|
-
#
|
|
724
|
-
#
|
|
849
|
+
self.setWindowTitle(f"Field Viewer - {self._file_labels[self.file_path]}")
|
|
850
|
+
# Bump the generation before anything else below - see __init__'s
|
|
851
|
+
# self._mesh_generation. A cached/in-flight clip result belongs to the
|
|
852
|
+
# mesh it was computed from: a new/different file needs a fresh clip
|
|
853
|
+
# regardless of whether the axis/position/sign happen to match the old
|
|
854
|
+
# cache key, and the old generation's clip-worker copy is now stale
|
|
855
|
+
# too (an old worker still running against it, if any, keeps its own
|
|
856
|
+
# reference via its constructor argument, so dropping ours here is
|
|
857
|
+
# safe, not a use-after-free).
|
|
858
|
+
self._mesh_generation += 1
|
|
725
859
|
self._clipped_mesh_cache = None
|
|
726
860
|
self._clipped_mesh_cache_key = None
|
|
861
|
+
self._full_mesh_for_clip = None
|
|
862
|
+
self._pending_clip_request = None
|
|
727
863
|
try:
|
|
728
864
|
self._full_mesh = _load_full_mesh(self.file_path)
|
|
729
865
|
except Exception as exc:
|
|
@@ -781,7 +917,7 @@ class FieldViewerWindow(QDialog):
|
|
|
781
917
|
self.clip_slider.blockSignals(True)
|
|
782
918
|
self.clip_slider.setValue(_SLIDER_STEPS // 2)
|
|
783
919
|
self.clip_slider.blockSignals(False)
|
|
784
|
-
self.
|
|
920
|
+
self._schedule_redraw()
|
|
785
921
|
|
|
786
922
|
def _slider_value_to_position(self):
|
|
787
923
|
"""Map the slider's integer [0, _SLIDER_STEPS] range to a real coordinate
|
|
@@ -811,16 +947,30 @@ class FieldViewerWindow(QDialog):
|
|
|
811
947
|
return round(fraction * _SLIDER_STEPS)
|
|
812
948
|
|
|
813
949
|
def _on_redraw_needed(self, _value=None):
|
|
814
|
-
self.
|
|
950
|
+
self._schedule_redraw()
|
|
951
|
+
|
|
952
|
+
def _on_clip_slider_changed(self, _value=None):
|
|
953
|
+
# Live label feedback on every tick, but only actually rebuild/render
|
|
954
|
+
# the mesh once the drag ends (sliderReleased, connected in
|
|
955
|
+
# _build_ui()) or for a non-drag change (keyboard, programmatic
|
|
956
|
+
# setValue()) - isSliderDown() is False for both of those, since
|
|
957
|
+
# neither involves a mouse press. See _schedule_redraw()'s docstring
|
|
958
|
+
# for why redrawing on every tick was unsafe/wasteful in the first
|
|
959
|
+
# place.
|
|
960
|
+
self._update_clip_position_label()
|
|
961
|
+
if not self.clip_slider.isSliderDown():
|
|
962
|
+
self._schedule_redraw()
|
|
815
963
|
|
|
816
964
|
def _on_opacity_changed(self, value):
|
|
817
965
|
self.opacity_label.setText(f"Opacity: {value}%")
|
|
818
|
-
self.
|
|
966
|
+
if not self.opacity_slider.isSliderDown():
|
|
967
|
+
self._schedule_redraw()
|
|
819
968
|
|
|
820
969
|
def _on_arrow_size_changed(self, value):
|
|
821
970
|
percent = value * _ARROW_SIZE_STEP_PERCENT
|
|
822
971
|
self.arrow_size_label.setText(f"Arrow size: {percent:g}%")
|
|
823
|
-
self.
|
|
972
|
+
if not self.arrow_size_slider.isSliderDown():
|
|
973
|
+
self._schedule_redraw()
|
|
824
974
|
|
|
825
975
|
# ---------- Axis views ----------
|
|
826
976
|
|
|
@@ -852,7 +1002,7 @@ class FieldViewerWindow(QDialog):
|
|
|
852
1002
|
camera_position = tuple(center + direction * distance)
|
|
853
1003
|
self.plotter.camera_position = [camera_position, tuple(center), _VIEW_UP[axis]]
|
|
854
1004
|
self.plotter.reset_camera()
|
|
855
|
-
self.
|
|
1005
|
+
self._schedule_redraw() # re-clips using the (possibly just-changed) sign for this axis, then renders
|
|
856
1006
|
|
|
857
1007
|
def _move_slider_to_max(self):
|
|
858
1008
|
"""Move the clip slider, along the currently selected axis, to the
|
|
@@ -888,14 +1038,14 @@ class FieldViewerWindow(QDialog):
|
|
|
888
1038
|
self.clip_enabled_cb.blockSignals(True)
|
|
889
1039
|
self.clip_enabled_cb.setChecked(True)
|
|
890
1040
|
self.clip_enabled_cb.blockSignals(False)
|
|
891
|
-
self.
|
|
1041
|
+
self._schedule_redraw()
|
|
892
1042
|
|
|
893
1043
|
# ---------- Field/array picker ----------
|
|
894
1044
|
|
|
895
1045
|
def _on_array_changed(self, _text=None):
|
|
896
1046
|
self._reset_clim_range()
|
|
897
1047
|
self._update_vector_checkbox_state()
|
|
898
|
-
self.
|
|
1048
|
+
self._schedule_redraw()
|
|
899
1049
|
|
|
900
1050
|
def _update_vector_checkbox_state(self):
|
|
901
1051
|
"""Enable "Show arrows" only when the currently selected Field array is
|
|
@@ -944,7 +1094,7 @@ class FieldViewerWindow(QDialog):
|
|
|
944
1094
|
|
|
945
1095
|
def _on_clim_reset_clicked(self):
|
|
946
1096
|
self._reset_clim_range()
|
|
947
|
-
self.
|
|
1097
|
+
self._schedule_redraw()
|
|
948
1098
|
|
|
949
1099
|
def _get_clim(self):
|
|
950
1100
|
"""(min, max) parsed from the Min/Max fields, or None to let PyVista
|
|
@@ -1025,13 +1175,40 @@ class FieldViewerWindow(QDialog):
|
|
|
1025
1175
|
|
|
1026
1176
|
# ---------- Redraw ----------
|
|
1027
1177
|
|
|
1178
|
+
def _update_clip_position_label(self):
|
|
1179
|
+
position = self._slider_value_to_position()
|
|
1180
|
+
position_um = position * _POSITION_SCALE_TO_UM.get(self.source, 1.0)
|
|
1181
|
+
self.clip_position_label.setText(f"Position: {position_um:.4g} um ({self._current_axis})")
|
|
1182
|
+
|
|
1183
|
+
def _schedule_redraw(self, *_args):
|
|
1184
|
+
"""Coalesce any number of redraw-triggering signals that fire before
|
|
1185
|
+
the event loop next turns into a single _redraw() call, reading
|
|
1186
|
+
whatever the final widget state is by then - not the state at the
|
|
1187
|
+
moment each signal fired. This is the same "only the latest request
|
|
1188
|
+
survives" pattern _request_clip() already applies to background clip
|
|
1189
|
+
requests specifically, generalized to the cheap synchronous path too
|
|
1190
|
+
(clip disabled, or an already-cached clip result), which had no such
|
|
1191
|
+
coalescing: without this, enough triggers stacking up in Qt's event
|
|
1192
|
+
queue faster than _apply_display_mesh() can complete on a large/
|
|
1193
|
+
complex mesh (e.g. a dense vector-arrow glyph rebuild) would each
|
|
1194
|
+
fully re-render in turn, wasting work before landing on the same
|
|
1195
|
+
final state one call would have reached directly. *_args absorbs
|
|
1196
|
+
whichever shape the connected signal carries (some pass a value,
|
|
1197
|
+
sliderReleased passes none) without needing a per-connection wrapper.
|
|
1198
|
+
"""
|
|
1199
|
+
if not self._redraw_scheduled:
|
|
1200
|
+
self._redraw_scheduled = True
|
|
1201
|
+
QTimer.singleShot(0, self._perform_scheduled_redraw)
|
|
1202
|
+
|
|
1203
|
+
def _perform_scheduled_redraw(self):
|
|
1204
|
+
self._redraw_scheduled = False
|
|
1205
|
+
self._redraw()
|
|
1206
|
+
|
|
1028
1207
|
def _redraw(self):
|
|
1029
1208
|
if self._full_mesh is None:
|
|
1030
1209
|
return
|
|
1031
1210
|
|
|
1032
|
-
|
|
1033
|
-
position_um = position * _POSITION_SCALE_TO_UM.get(self.source, 1.0)
|
|
1034
|
-
self.clip_position_label.setText(f"Position: {position_um:.4g} um ({self._current_axis})")
|
|
1211
|
+
self._update_clip_position_label()
|
|
1035
1212
|
|
|
1036
1213
|
if not self.clip_enabled_cb.isChecked():
|
|
1037
1214
|
# A clip that's still computing in the background (if any) is now
|
|
@@ -1044,8 +1221,9 @@ class FieldViewerWindow(QDialog):
|
|
|
1044
1221
|
# _set_view()/_clip_sign) - same cut location either way, but flips
|
|
1045
1222
|
# which side is kept so the cut face faces whichever side the camera
|
|
1046
1223
|
# was last pointed at.
|
|
1224
|
+
position = self._slider_value_to_position()
|
|
1047
1225
|
sign = self._clip_sign.get(self._current_axis, 1)
|
|
1048
|
-
cache_key = (self._current_axis, position, sign)
|
|
1226
|
+
cache_key = (self._current_axis, position, sign, self._mesh_generation)
|
|
1049
1227
|
if cache_key == self._clipped_mesh_cache_key:
|
|
1050
1228
|
# The clip geometry itself hasn't changed since the last computed
|
|
1051
1229
|
# result - this redraw is for something else entirely (opacity,
|
|
@@ -1059,7 +1237,7 @@ class FieldViewerWindow(QDialog):
|
|
|
1059
1237
|
|
|
1060
1238
|
self._request_clip(*cache_key)
|
|
1061
1239
|
|
|
1062
|
-
def _request_clip(self, axis, position, sign):
|
|
1240
|
+
def _request_clip(self, axis, position, sign, generation):
|
|
1063
1241
|
"""Kick off a background clip for this axis/position/sign, unless one
|
|
1064
1242
|
is already running - in that case just remember these as the latest
|
|
1065
1243
|
desired parameters (_pending_clip_request) instead of starting a
|
|
@@ -1070,7 +1248,7 @@ class FieldViewerWindow(QDialog):
|
|
|
1070
1248
|
up every intermediate one.
|
|
1071
1249
|
"""
|
|
1072
1250
|
if self._clip_thread is not None and self._clip_thread.isRunning():
|
|
1073
|
-
if (axis, position, sign) == self._active_clip_key:
|
|
1251
|
+
if (axis, position, sign, generation) == self._active_clip_key:
|
|
1074
1252
|
# Already computing exactly this geometry - its result will
|
|
1075
1253
|
# satisfy this request too once it lands, since
|
|
1076
1254
|
# _on_clip_succeeded() checks against the then-current
|
|
@@ -1078,24 +1256,34 @@ class FieldViewerWindow(QDialog):
|
|
|
1078
1256
|
# so there's nothing to gain from queuing a duplicate.
|
|
1079
1257
|
self._pending_clip_request = None
|
|
1080
1258
|
return
|
|
1081
|
-
self._pending_clip_request = (axis, position, sign)
|
|
1259
|
+
self._pending_clip_request = (axis, position, sign, generation)
|
|
1082
1260
|
return
|
|
1083
|
-
self._start_clip_thread(axis, position, sign)
|
|
1261
|
+
self._start_clip_thread(axis, position, sign, generation)
|
|
1084
1262
|
|
|
1085
|
-
def _start_clip_thread(self, axis, position, sign):
|
|
1263
|
+
def _start_clip_thread(self, axis, position, sign, generation):
|
|
1086
1264
|
self._pending_clip_request = None
|
|
1087
|
-
self._active_clip_key = (axis, position, sign)
|
|
1265
|
+
self._active_clip_key = (axis, position, sign, generation)
|
|
1088
1266
|
# Scoped to this window (not QApplication.setOverrideCursor()) - only
|
|
1089
1267
|
# this field-viewer window is actually busy; the main setupEM/
|
|
1090
1268
|
# setupThermal window (and any other open field viewer) stays fully
|
|
1091
1269
|
# usable and shouldn't look busy too. Set once per coalesced chain of
|
|
1092
1270
|
# clips (guarded so a mid-chain restart in _maybe_start_pending_clip()
|
|
1093
1271
|
# doesn't set it again), cleared once the chain truly settles - see
|
|
1094
|
-
# _clear_busy_cursor().
|
|
1272
|
+
# _clear_busy_cursor(). Covers the one-time mesh copy just below too,
|
|
1273
|
+
# which is a real (if much smaller than the clip itself) synchronous
|
|
1274
|
+
# cost on a large mesh.
|
|
1095
1275
|
if not self._clip_busy_cursor_active:
|
|
1096
1276
|
self.setCursor(Qt.WaitCursor)
|
|
1097
1277
|
self._clip_busy_cursor_active = True
|
|
1098
|
-
|
|
1278
|
+
# Lazily create this generation's private clip-worker copy - see its
|
|
1279
|
+
# ownership contract in __init__. Reused for every subsequent clip
|
|
1280
|
+
# against this same mesh (safe: mesh.clip() doesn't mutate its input,
|
|
1281
|
+
# and only one _ClipWorker ever runs at a time against it), and reset
|
|
1282
|
+
# to None by _load_mesh() for the next generation.
|
|
1283
|
+
if self._full_mesh_for_clip is None:
|
|
1284
|
+
self._full_mesh_for_clip = self._full_mesh.copy()
|
|
1285
|
+
assert self._full_mesh_for_clip is not self._full_mesh
|
|
1286
|
+
thread = _ClipWorker(self._full_mesh_for_clip, axis, position, sign, generation)
|
|
1099
1287
|
thread.succeeded.connect(self._on_clip_succeeded)
|
|
1100
1288
|
thread.failed.connect(self._on_clip_failed)
|
|
1101
1289
|
self._clip_thread = thread
|
|
@@ -1119,13 +1307,15 @@ class FieldViewerWindow(QDialog):
|
|
|
1119
1307
|
self._clip_thread.wait()
|
|
1120
1308
|
self._clip_thread = None
|
|
1121
1309
|
|
|
1122
|
-
def _on_clip_succeeded(self, clipped_mesh, axis, position, sign):
|
|
1310
|
+
def _on_clip_succeeded(self, clipped_mesh, axis, position, sign, generation):
|
|
1123
1311
|
self._retire_clip_thread()
|
|
1124
|
-
result_key = (axis, position, sign)
|
|
1312
|
+
result_key = (axis, position, sign, generation)
|
|
1125
1313
|
# Compare against what's CURRENTLY desired (not "was this the most
|
|
1126
1314
|
# recent request") - if nothing but the geometry key matters, a
|
|
1127
1315
|
# result stays usable even if unrelated redraws (opacity, color
|
|
1128
|
-
# scale, ...) happened while this was still computing.
|
|
1316
|
+
# scale, ...) happened while this was still computing. The generation
|
|
1317
|
+
# element also rejects a result computed for a file that's since been
|
|
1318
|
+
# switched away from, even if axis/position/sign happen to coincide.
|
|
1129
1319
|
if result_key == self._current_clip_key():
|
|
1130
1320
|
self.warning_label.setText("")
|
|
1131
1321
|
self._clipped_mesh_cache = clipped_mesh
|
|
@@ -1133,9 +1323,9 @@ class FieldViewerWindow(QDialog):
|
|
|
1133
1323
|
self._apply_display_mesh(clipped_mesh)
|
|
1134
1324
|
self._maybe_start_pending_clip()
|
|
1135
1325
|
|
|
1136
|
-
def _on_clip_failed(self, message, axis, position, sign):
|
|
1326
|
+
def _on_clip_failed(self, message, axis, position, sign, generation):
|
|
1137
1327
|
self._retire_clip_thread()
|
|
1138
|
-
result_key = (axis, position, sign)
|
|
1328
|
+
result_key = (axis, position, sign, generation)
|
|
1139
1329
|
if result_key == self._current_clip_key():
|
|
1140
1330
|
self.warning_label.setText(f"Clip failed: {message}")
|
|
1141
1331
|
self._apply_display_mesh(self._full_mesh)
|
|
@@ -1148,18 +1338,20 @@ class FieldViewerWindow(QDialog):
|
|
|
1148
1338
|
self._clear_busy_cursor()
|
|
1149
1339
|
|
|
1150
1340
|
def _current_clip_key(self):
|
|
1151
|
-
"""(axis, position, sign) the clip plane is currently set
|
|
1152
|
-
if clipping is off - the ground truth a background clip
|
|
1153
|
-
checked against before being applied (see
|
|
1154
|
-
recomputed fresh rather than cached, since the
|
|
1155
|
-
catch cases where the desired state has moved on
|
|
1156
|
-
requested
|
|
1341
|
+
"""(axis, position, sign, generation) the clip plane is currently set
|
|
1342
|
+
to, or None if clipping is off - the ground truth a background clip
|
|
1343
|
+
result is checked against before being applied (see
|
|
1344
|
+
_on_clip_succeeded()), recomputed fresh rather than cached, since the
|
|
1345
|
+
whole point is to catch cases where the desired state has moved on
|
|
1346
|
+
since the result was requested - including a file switch, via
|
|
1347
|
+
self._mesh_generation."""
|
|
1157
1348
|
if not self.clip_enabled_cb.isChecked():
|
|
1158
1349
|
return None
|
|
1159
1350
|
return (
|
|
1160
1351
|
self._current_axis,
|
|
1161
1352
|
self._slider_value_to_position(),
|
|
1162
1353
|
self._clip_sign.get(self._current_axis, 1),
|
|
1354
|
+
self._mesh_generation,
|
|
1163
1355
|
)
|
|
1164
1356
|
|
|
1165
1357
|
def _clear_busy_cursor(self):
|
|
@@ -70,6 +70,19 @@ def find_paraview_files(run_path, model_basename):
|
|
|
70
70
|
return sorted(glob.glob(os.path.join(output_dir, '**', '*.vtu'), recursive=True))
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def is_amr_iteration_path(path):
|
|
74
|
+
"""True if `path` sits under (or is itself) one of the "iteration<N>" output
|
|
75
|
+
subfolders an adaptive mesh refinement run writes alongside its final,
|
|
76
|
+
most-refined pass at the output root - see _list_iteration_dirs(). Used by
|
|
77
|
+
field_viewer.py to default its Result File picker to the final pass's field
|
|
78
|
+
dumps only: AMR's earlier iterations exist for convergence tracking (see
|
|
79
|
+
build_results_summary()), not usually meant to be inspected in 3D themselves,
|
|
80
|
+
and a run with several iterations otherwise multiplies the picker's entries
|
|
81
|
+
by the iteration count.
|
|
82
|
+
"""
|
|
83
|
+
return any(_ITERATION_RE.match(part) for part in os.path.normpath(path).split(os.sep))
|
|
84
|
+
|
|
85
|
+
|
|
73
86
|
def _read_palace_json(dir_path):
|
|
74
87
|
path = os.path.join(dir_path, 'palace.json')
|
|
75
88
|
if not os.path.isfile(path):
|
|
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
|
|
File without changes
|
|
File without changes
|