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.
Files changed (31) hide show
  1. {setupem-0.8.1/src/setupEM.egg-info → setupem-0.8.2}/PKG-INFO +1 -1
  2. {setupem-0.8.1 → setupem-0.8.2}/README.md +5 -1
  3. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/__init__.py +1 -1
  4. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/field_viewer.py +270 -78
  5. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/palace_results.py +13 -0
  6. {setupem-0.8.1 → setupem-0.8.2/src/setupEM.egg-info}/PKG-INFO +1 -1
  7. {setupem-0.8.1 → setupem-0.8.2}/LICENSE +0 -0
  8. {setupem-0.8.1 → setupem-0.8.2}/README_pypi.md +0 -0
  9. {setupem-0.8.1 → setupem-0.8.2}/pyproject.toml +0 -0
  10. {setupem-0.8.1 → setupem-0.8.2}/setup.cfg +0 -0
  11. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/__main__.py +0 -0
  12. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_100um.xml +0 -0
  13. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_200um.xml +0 -0
  14. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_200um_with_parameters.xml +0 -0
  15. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/data/SG13G2_nosub.xml +0 -0
  16. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/gds_hierarchy_scan.py +0 -0
  17. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/layout_preview.py +0 -0
  18. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/momentum_import.py +0 -0
  19. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/result_viewer.py +0 -0
  20. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setupEM.py +0 -0
  21. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setupThermal.py +0 -0
  22. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/setup_common.py +0 -0
  23. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/simplify_gds.py +0 -0
  24. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/stackupEditor.py +0 -0
  25. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/stackup_writer.py +0 -0
  26. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM/thermal_results.py +0 -0
  27. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/SOURCES.txt +0 -0
  28. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/dependency_links.txt +0 -0
  29. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/entry_points.txt +0 -0
  30. {setupem-0.8.1 → setupem-0.8.2}/src/setupEM.egg-info/requires.txt +0 -0
  31. {setupem-0.8.1 → setupem-0.8.2}/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.8.1
3
+ Version: 0.8.2
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
@@ -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.
@@ -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.8.1"
27
+ __version__ = "0.8.2"
@@ -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(). Emits exactly
270
- one of succeeded/failed, tagged with the (axis, position, sign) it was
271
- computed for, so a result that's no longer relevant (the user has since
272
- moved to a different axis/position/sign) can be told apart from one
273
- that's still current - see FieldViewerWindow._on_clip_succeeded(), which
274
- compares this against the currently-desired key rather than trusting an
275
- opaque "is this the latest request" counter, so a result stays usable
276
- even if something unrelated (opacity, color scale, ...) redrew in the
277
- meantime while this was still computing.
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; neither is
379
- inherently the "right" one to default to, so the user picks) - with a single
380
- axis-aligned clip plane and a field/array picker."""
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
- self.file_path = self.file_paths[0]
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 triggers (opacity,
432
- # log scale, clim, mesh overlay, array selection, vector arrows) don't
433
- # change the clip geometry at all - only axis/position/sign do - so
434
- # reusing this avoids kicking off another expensive background clip
435
- # for a purely cosmetic change. Also closes a real crash: on Linux and
436
- # Windows, dragging the opacity slider (which fires many rapid
437
- # _redraw() calls) was starting/finishing background clip threads in
438
- # quick succession, occasionally destroying a _ClipWorker just before
439
- # Qt considered it fully stopped ("QThread: Destroyed while thread is
440
- # still running") - this cache means opacity changes no longer touch
441
- # the clip thread machinery at all once one clip result exists for
442
- # the current axis/position. See _redraw()/_on_clip_succeeded().
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 - both
487
- # equally valid, neither an inherently better default) - see __init__.
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._file_label(p) for p in self.file_paths])
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._on_redraw_needed)
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 _file_label(path):
700
- """<parent folder>/<filename> - enough to tell e.g. Palace's "driven" and
701
- "driven_boundary" collections apart at a glance, without the full path."""
702
- return f"{os.path.basename(os.path.dirname(path))}/{os.path.basename(path)}"
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.file_paths):
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 = self.file_paths[index]
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._file_label(self.file_path)}")
722
- # A cached clip result belongs to the mesh it was computed from - a
723
- # new/different file needs a fresh clip regardless of whether the
724
- # axis/position/sign happen to match the old cache key.
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._redraw()
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._redraw()
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._redraw()
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._redraw()
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._redraw() # re-clips using the (possibly just-changed) sign for this axis, then renders
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._redraw()
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._redraw()
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._redraw()
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
- position = self._slider_value_to_position()
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
- thread = _ClipWorker(self._full_mesh, axis, position, sign)
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 to, or None
1152
- if clipping is off - the ground truth a background clip result is
1153
- checked against before being applied (see _on_clip_succeeded()),
1154
- recomputed fresh rather than cached, since the whole point is to
1155
- catch cases where the desired state has moved on since the result was
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):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setupEM
3
- Version: 0.8.1
3
+ Version: 0.8.2
4
4
  Summary: Python tool for configuration of gds2palace workflow with GUI.
5
5
  Author-email: Volker Muehlhaus <volker@muehlhaus.com>
6
6
  License-Expression: GPL-3.0-or-later
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes