Skip to content

Commit df39fac

Browse files
Make plot generation asynchronous to keep the UI responsive (#171)
Co-authored-by: Patrick Shriwise <pshriwise@gmail.com>
1 parent 12f9f18 commit df39fac

File tree

4 files changed

+321
-55
lines changed

4 files changed

+321
-55
lines changed

openmc_plotter/main_window.py

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def __init__(self,
5656
self.model_path = Path(model_path)
5757
self.threads = threads
5858
self.default_res = resolution
59+
self.model = None
60+
self.plot_manager = None
5961

6062
def loadGui(self, use_settings_pkl=True):
6163

@@ -114,15 +116,19 @@ def loadGui(self, use_settings_pkl=True):
114116
self.statusBar().addPermanentWidget(self.coord_label)
115117
self.coord_label.hide()
116118

119+
self.plot_manager = self.model.plot_manager
120+
self.plot_manager.plot_started.connect(self._on_plot_started)
121+
self.plot_manager.plot_queued.connect(self._on_plot_queued)
122+
self.plot_manager.plot_finished.connect(self._on_plot_finished)
123+
self.plot_manager.plot_error.connect(self._on_plot_error)
124+
self.plot_manager.plot_idle.connect(self._on_plot_idle)
125+
117126
# Load Plot
118-
self.statusBar().showMessage('Generating Plot...')
119127
self.geometryPanel.update()
120128
self.tallyPanel.update()
121129
self.colorDialog.updateDialogValues()
122-
self.statusBar().showMessage('')
123130

124-
# Timer allows GUI to render before plot finishes loading
125-
QtCore.QTimer.singleShot(0, self.showCurrentView)
131+
QtCore.QTimer.singleShot(0, self.requestPlotUpdate)
126132

127133
self.plotIm.frozen = False
128134

@@ -421,10 +427,17 @@ def updateEditMenu(self):
421427
changed = self.model.currentView != self.model.defaultView
422428
self.restoreAction.setDisabled(not changed)
423429

430+
toggle_actions = (self.maskingAction, self.highlightingAct,
431+
self.outlineAct, self.overlapAct)
432+
# Temporarily block signals to avoid triggering plot update
433+
for action in toggle_actions:
434+
action.blockSignals(True)
424435
self.maskingAction.setChecked(self.model.currentView.masking)
425436
self.highlightingAct.setChecked(self.model.currentView.highlighting)
426437
self.outlineAct.setChecked(self.model.currentView.outlinesCell)
427438
self.overlapAct.setChecked(self.model.currentView.color_overlaps)
439+
for action in toggle_actions:
440+
action.blockSignals(False)
428441

429442
num_previous_views = len(self.model.previousViews)
430443
self.undoAction.setText('&Undo ({})'.format(num_previous_views))
@@ -466,11 +479,14 @@ def saveBatchImage(self, view_file):
466479
cv = self.model.currentView
467480
# load the view from file
468481
self.loadViewFile(view_file)
482+
self.waitForPlotIdle()
469483
self.plotIm.saveImage(view_file.replace('.pltvw', ''))
470484

471485
# Menu and shared methods
472486
def loadModel(self, reload=False, use_settings_pkl=True):
473487
if reload:
488+
if self.plot_manager is not None:
489+
self.plot_manager.wait_for_idle()
474490
self.resetModels()
475491
else:
476492
self.model = PlotModel(use_settings_pkl, self.model_path, self.default_res)
@@ -632,66 +648,48 @@ def plotSourceSites(self):
632648

633649
def applyChanges(self):
634650
if self.model.activeView != self.model.currentView:
635-
self.statusBar().showMessage('Generating Plot...')
636-
QApplication.processEvents()
637651
if self.model.activeView.selectedTally is not None:
638652
self.tallyPanel.updateModel()
639653
self.updateMeshAnnotations()
640654
self.model.storeCurrent()
641655
self.model.subsequentViews = []
642-
self.plotIm.generatePixmap()
643-
self.resetModels()
644-
self.showCurrentView()
645-
self.statusBar().showMessage('')
656+
self.requestPlotUpdate()
646657
else:
647658
self.statusBar().showMessage('No changes to apply.', 3000)
648659

649660
def undo(self):
650-
self.statusBar().showMessage('Generating Plot...')
651-
QApplication.processEvents()
652-
661+
if not self.model.previousViews:
662+
return
653663
self.model.undo()
654-
self.resetModels()
655-
self.showCurrentView()
656664
self.geometryPanel.update()
657665
self.colorDialog.updateDialogValues()
666+
self.requestPlotUpdate()
658667

659668
if not self.model.previousViews:
660669
self.undoAction.setDisabled(True)
661670
self.redoAction.setDisabled(False)
662-
self.statusBar().showMessage('')
663671

664672
def redo(self):
665-
self.statusBar().showMessage('Generating Plot...')
666-
QApplication.processEvents()
667-
673+
if not self.model.subsequentViews:
674+
return
668675
self.model.redo()
669-
self.resetModels()
670-
self.showCurrentView()
671676
self.geometryPanel.update()
672677
self.colorDialog.updateDialogValues()
678+
self.requestPlotUpdate()
673679

674680
if not self.model.subsequentViews:
675681
self.redoAction.setDisabled(True)
676682
self.undoAction.setDisabled(False)
677-
self.statusBar().showMessage('')
678683

679684
def restoreDefault(self):
680685
if self.model.currentView != self.model.defaultView:
681-
682-
self.statusBar().showMessage('Generating Plot...')
683-
QApplication.processEvents()
684-
685686
self.model.storeCurrent()
686687
self.model.activeView.adopt_plotbase(self.model.defaultView)
687-
self.plotIm.generatePixmap()
688-
self.resetModels()
689-
self.showCurrentView()
690688
self.geometryPanel.update()
691689
self.colorDialog.updateDialogValues()
690+
self.requestPlotUpdate()
692691

693692
self.model.subsequentViews = []
694-
self.statusBar().showMessage('')
695693

696694
def editBasis(self, basis, apply=False):
697695
self.model.activeView.basis = basis
@@ -1199,6 +1197,9 @@ def resizeEvent(self, event):
11991197
self.shortcutOverlay.resize(self.width(), self.height())
12001198

12011199
def closeEvent(self, event):
1200+
if self.plot_manager is not None:
1201+
self.plot_manager.wait_for_idle(timeout_ms=250)
1202+
self.plot_manager.shutdown()
12021203
settings = QtCore.QSettings()
12031204
settings.setValue("mainWindow/Size", self.size())
12041205
settings.setValue("mainWindow/Position", self.pos())
@@ -1213,6 +1214,53 @@ def closeEvent(self, event):
12131214

12141215
self.saveSettings()
12151216

1217+
def requestPlotUpdate(self, view=None):
1218+
if self.model is None:
1219+
return
1220+
if view is None:
1221+
view = self.model.activeView
1222+
view_snapshot = copy.deepcopy(view)
1223+
if self.model.can_reuse_maps(view_snapshot):
1224+
view_params = self.model.view_params_payload(view_snapshot)
1225+
self.plot_manager.set_latest_view_params(view_params)
1226+
self.plot_manager.clear_pending()
1227+
self.model.makePlot(view_snapshot, self.model.ids_map, self.model.properties)
1228+
self.resetModels()
1229+
self.showCurrentView()
1230+
if not self.plot_manager.is_busy:
1231+
self._on_plot_idle()
1232+
return
1233+
view_params = self.model.view_params_payload(view_snapshot)
1234+
self.plot_manager.enqueue(view_snapshot, view_params)
1235+
1236+
def waitForPlotIdle(self, timeout_ms=None):
1237+
if self.plot_manager is not None:
1238+
return self.plot_manager.wait_for_idle(timeout_ms)
1239+
return True
1240+
1241+
def _on_plot_started(self):
1242+
self.plotIm.showUpdatingOverlay("Generating Plot...")
1243+
1244+
def _on_plot_queued(self):
1245+
self.plotIm.showUpdatingOverlay("Generating Plot... (update queued)")
1246+
1247+
def _on_plot_finished(self, view_snapshot, view_params, ids_map, properties):
1248+
if view_params != self.plot_manager.latest_view_params:
1249+
return
1250+
self.model.makePlot(view_snapshot, ids_map, properties)
1251+
self.resetModels()
1252+
self.showCurrentView()
1253+
1254+
def _on_plot_error(self, error_msg):
1255+
msg_box = QMessageBox()
1256+
msg_box.setText(f"Failed to generate plot:\n\n{error_msg}")
1257+
msg_box.setIcon(QMessageBox.Warning)
1258+
msg_box.setStandardButtons(QMessageBox.Ok)
1259+
msg_box.exec()
1260+
1261+
def _on_plot_idle(self):
1262+
self.plotIm.hideUpdatingOverlay()
1263+
12161264
def saveSettings(self):
12171265
if self.model.statepoint:
12181266
self.model.statepoint.close()

openmc_plotter/plotgui.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from PySide6 import QtCore, QtGui
44
from PySide6.QtWidgets import (QWidget, QPushButton, QHBoxLayout, QVBoxLayout,
5-
QFormLayout, QComboBox, QSpinBox,
5+
QFormLayout, QComboBox, QSpinBox, QLabel,
66
QDoubleSpinBox, QSizePolicy, QMessageBox,
77
QCheckBox, QRubberBand, QMenu, QDialog,
88
QTabWidget, QTableView, QHeaderView)
@@ -21,6 +21,33 @@
2121
from .custom_widgets import HorizontalLine
2222

2323

24+
class PlotUpdateOverlay(QWidget):
25+
26+
def __init__(self, parent=None):
27+
super().__init__(parent)
28+
self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True)
29+
self.setAttribute(QtCore.Qt.WA_StyledBackground, True)
30+
self.setFocusPolicy(QtCore.Qt.NoFocus)
31+
self.setStyleSheet("background-color: rgba(20, 20, 20, 140);")
32+
33+
layout = QVBoxLayout(self)
34+
layout.setContentsMargins(0, 0, 0, 0)
35+
layout.setAlignment(QtCore.Qt.AlignCenter)
36+
37+
self.label = QLabel("Generating Plot...", self)
38+
self.label.setAlignment(QtCore.Qt.AlignCenter)
39+
font = self.label.font()
40+
font.setPointSize(max(12, font.pointSize() + 6))
41+
self.label.setFont(font)
42+
self.label.setStyleSheet("color: white; background-color: transparent;")
43+
layout.addWidget(self.label)
44+
45+
self.hide()
46+
47+
def set_message(self, message: str):
48+
self.label.setText(message)
49+
50+
2451

2552
class PlotImage(FigureCanvas):
2653

@@ -51,13 +78,15 @@ def __init__(self, model: PlotModel, parent, main_window):
5178
self.tally_colorbar = None
5279
self.tally_image = None
5380
self.image = None
81+
self.ax = None
5482

5583
self._property_colorbar_bg = None
5684
self._tally_colorbar_bg = None
5785
self._last_tally_indicator_value = None
5886
self._last_data_indicator_value = None
5987

6088
self.menu = QMenu(self)
89+
self.update_overlay = PlotUpdateOverlay(self)
6190

6291
def enterEvent(self, event):
6392
self.setCursor(QtCore.Qt.CrossCursor)
@@ -79,6 +108,8 @@ def mousePressEvent(self, event):
79108
QtCore.QSize()))
80109

81110
def getPlotCoords(self, pos):
111+
if self.ax is None:
112+
return (0.0, 0.0)
82113
x, y = self.mouseEventCoords(pos)
83114

84115
# get the normalized axis coordinates from the event display units
@@ -119,6 +150,24 @@ def _resize(self):
119150
self.resize(self.parent.width() * z,
120151
self.parent.height() * z)
121152

153+
def resizeEvent(self, event):
154+
super().resizeEvent(event)
155+
if self.update_overlay is not None:
156+
self.update_overlay.setGeometry(self.rect())
157+
158+
def showUpdatingOverlay(self, message: str = "Generating Plot..."):
159+
if self.update_overlay is None:
160+
return
161+
self.update_overlay.set_message(message)
162+
self.update_overlay.setGeometry(self.rect())
163+
self.update_overlay.raise_()
164+
self.update_overlay.show()
165+
166+
def hideUpdatingOverlay(self):
167+
if self.update_overlay is None:
168+
return
169+
self.update_overlay.hide()
170+
122171
def saveImage(self, filename):
123172
"""Save an image of the current view
124173
@@ -238,6 +287,8 @@ def mouseDoubleClickEvent(self, event):
238287

239288
def mouseMoveEvent(self, event):
240289
cv = self.model.currentView
290+
if self.ax is None or self.model.image is None:
291+
return
241292
# Show Cursor position relative to plot in status bar
242293
xPlotPos, yPlotPos = self.getPlotCoords(event.pos())
243294

@@ -343,6 +394,11 @@ def mouseReleaseEvent(self, event):
343394
self.rubber_band.hide()
344395
self.main_window.applyChanges()
345396
else:
397+
plot_manager = self.main_window.plot_manager
398+
if plot_manager.is_busy or plot_manager.has_pending:
399+
return
400+
if self.main_window.model.activeView != self.main_window.model.currentView:
401+
return
346402
self.main_window.revertDockControls()
347403

348404
def wheelEvent(self, event):
@@ -479,9 +535,7 @@ def generatePixmap(self, update=False):
479535
if self.frozen:
480536
return
481537

482-
self.model.generatePlot()
483-
if update:
484-
self.updatePixmap()
538+
self.main_window.requestPlotUpdate()
485539

486540
def updatePixmap(self):
487541

@@ -500,9 +554,8 @@ def updatePixmap(self):
500554
cv.origin[self.main_window.yBasis] - cv.height/2.,
501555
cv.origin[self.main_window.yBasis] + cv.height/2.]
502556

503-
# make sure we have a domain image to load
504-
if not hasattr(self.model, 'image'):
505-
self.model.generatePlot()
557+
if not hasattr(self.model, 'image') or self.model.image is None:
558+
return
506559

507560
### DRAW DOMAIN IMAGE ###
508561

0 commit comments

Comments
 (0)