diff --git a/src/dashpva/gui/theme.qss b/src/dashpva/gui/theme.qss index 9e518b0b..114a89de 100644 --- a/src/dashpva/gui/theme.qss +++ b/src/dashpva/gui/theme.qss @@ -312,20 +312,25 @@ QPlainTextEdit#txt_notes { QTabWidget#tabWidget QTabBar::tab:first { background-color: palette(button); } QTabWidget#tabWidget QTabBar::tab:first:selected { background-color: palette(base); } QWidget#simServerTab { background-color: $SIM_BG; } -QLabel#labelConfigSourceTitle { font-weight: bold; color: $TEXT_SECONDARY; } -QRadioButton#radioLegacyToml { color: $WARNING; font-weight: bold; } -QRadioButton#radioDatabase { color: $INFO; font-weight: bold; } QLabel#labelDbStatus { color: $DISABLED_BG; font-size: $FONT_SMALL; } -QPushButton#buttonInitDb, QPushButton#buttonRefreshDb { font-size: $FONT_BODY; padding: 4px 12px; min-height: 28px; } -QLabel#labelTomlHeader { - color: $WARNING; - font-weight: bold; - font-size: $FONT_CAPTION; - background-color: palette(button); - border-left: 3px solid $WARNING; - padding: 2px 6px; +QPushButton#buttonInitDb, +QPushButton#buttonRefreshDb, +QPushButton#buttonReseed, +QPushButton#buttonAddProfile, +QPushButton#buttonDeleteProfile, +QPushButton#buttonRenameProfile, +QPushButton#buttonDuplicateProfile, +QPushButton#buttonImportToml, +QPushButton#buttonExportConfigToFile, +QPushButton#buttonClearChanges, +QPushButton#buttonApplySave { + font-size: $FONT_BODY; + min-height: 30px; + padding: 4px 10px; } -QFrame#separatorLine { color: palette(mid); } +QPushButton#buttonInitDb, +QPushButton#buttonRefreshDb, +QPushButton#buttonReseed { min-width: 150px; } QLabel#labelViewMode { color: palette(text); font-weight: bold; @@ -342,7 +347,6 @@ QLabel#labelDatabaseHeader { border-left: 3px solid $INFO; padding: 2px 6px; } -QLabel#labelTomlHeader[sectionActive="false"], QLabel#labelDatabaseHeader[sectionActive="false"] { background-color: palette(button); color: palette(mid); @@ -353,15 +357,18 @@ QTreeWidget#treeWidgetConfig[sectionActive="false"] { color: palette(mid); } QLabel#labelProfile, QLabel#labelProfileDescription { color: $INFO; font-weight: bold; } +QLabel[memoryEstimate="true"] { + color: $TEXT_MUTED; + font-style: italic; + font-size: $FONT_CAPTION; +} QComboBox#comboBoxProfile { border: 1px solid $INFO; border-radius: 3px; padding: 2px 6px; } -QCheckBox#checkBoxDefaultProfile { color: $WARNING; font-size: $FONT_CAPTION; } -QCheckBox#checkBoxSelectedProfile { color: $SUCCESS; font-size: $FONT_CAPTION; } QLineEdit#lineEditProfileDescription { color: $TEXT_SECONDARY; background: transparent; border: none; } QPushButton#buttonCollapseAll, QPushButton#buttonExpandAll { font-size: $FONT_BODY; padding: 4px 8px; min-height: 28px; } QPushButton#buttonAddTopLevel { font-size: $FONT_SMALL; padding: 4px 8px; min-height: 28px; } -QPushButton#buttonAddProfile { color: $SUCCESS; border: 1px solid $SUCCESS; border-radius: 4px; padding: 6px 14px; } +QPushButton#buttonAddProfile { color: $SUCCESS; border: 1px solid $SUCCESS; border-radius: 4px; } QPushButton#buttonAddProfile:disabled { color: palette(mid); border-color: palette(mid); } -QPushButton#buttonDeleteProfile { color: $ERROR; border: 1px solid $ERROR; border-radius: 4px; padding: 6px 14px; } +QPushButton#buttonDeleteProfile { color: $ERROR; border: 1px solid $ERROR; border-radius: 4px; } QPushButton#buttonDeleteProfile:disabled { color: palette(mid); border-color: palette(mid); } /* -- Settings dialog (SettingsDialog) --------------------------------------- */ diff --git a/src/dashpva/scripts/seed_settings_defaults_sql.py b/src/dashpva/scripts/seed_settings_defaults_sql.py index 17827122..b5f89b2d 100644 --- a/src/dashpva/scripts/seed_settings_defaults_sql.py +++ b/src/dashpva/scripts/seed_settings_defaults_sql.py @@ -79,51 +79,64 @@ def get_or_create_setting(cur, name: str, type_: str, desc: str = "", parent_id= return int(cur.lastrowid) -def add_value_if_missing(cur, setting_id: int, key: str, value: str, value_type: str = "string") -> None: +def add_value_if_missing(cur, setting_id: int, key: str, value: str, value_type: str = "string") -> bool: cur.execute( "SELECT id FROM setting_values WHERE setting_id=? AND key=?", (setting_id, key), ) if cur.fetchone(): - return + return False cur.execute( "INSERT INTO setting_values (setting_id, key, value, value_type) VALUES (?, ?, ?, ?)", (setting_id, key, value, value_type), ) + return True -def seed_defaults() -> None: +def seed_defaults() -> list[str]: if not _DB_FILE.exists(): - return + return [] + added = [] conn = sqlite3.connect(DB_PATH) try: cur = conn.cursor() # ── BEAMLINE_NAME ─────────────────────────────────────────────────── # beamline_id = get_or_create_setting(cur, "BEAMLINE_NAME", "string", "Beamline identification", None) - add_value_if_missing(cur, beamline_id, "BEAMLINE_NAME", "") + if add_value_if_missing(cur, beamline_id, "BEAMLINE_NAME", ""): + added.append("BEAMLINE_NAME") # ── PATHS ─────────────────────────────────────────────────────────── # paths_id = get_or_create_setting(cur, "PATHS", "root", "Application paths", None) log_id = get_or_create_setting(cur, "LOG", "path", "Logs directory", paths_id) - add_value_if_missing(cur, log_id, "BASE", "logs") + if add_value_if_missing(cur, log_id, "BASE", "logs"): + added.append("PATHS.LOG.BASE") configs_id = get_or_create_setting(cur, "CONFIGS", "path", "PV configs directory", paths_id) - add_value_if_missing(cur, configs_id, "BASE", "configs") + if add_value_if_missing(cur, configs_id, "BASE", "configs"): + added.append("PATHS.CONFIGS.BASE") outputs_id = get_or_create_setting(cur, "OUTPUTS", "path", "Outputs directory", paths_id) - add_value_if_missing(cur, outputs_id, "BASE", "outputs") + if add_value_if_missing(cur, outputs_id, "BASE", "outputs"): + added.append("PATHS.OUTPUTS.BASE") scan_id = get_or_create_setting(cur, "SCAN", "path", "Scans directory", outputs_id) - add_value_if_missing(cur, scan_id, "BASE", "scans") + if add_value_if_missing(cur, scan_id, "BASE", "scans"): + added.append("PATHS.OUTPUTS.SCAN.BASE") slices_id = get_or_create_setting(cur, "SLICES", "path", "Slices directory", outputs_id) - add_value_if_missing(cur, slices_id, "BASE", "slices") + if add_value_if_missing(cur, slices_id, "BASE", "slices"): + added.append("PATHS.OUTPUTS.SLICES.BASE") # ── CONSUMERS (under PATHS) ───────────────────────────────────────── # consumers_id = get_or_create_setting(cur, "CONSUMERS", "path", "Consumer directories", paths_id) - add_value_if_missing(cur, consumers_id, "BASE", "consumers") - add_value_if_missing(cur, consumers_id, "IOC", "caIOC_servers") + if add_value_if_missing(cur, consumers_id, "BASE", "consumers"): + added.append("PATHS.CONSUMERS.BASE") + if add_value_if_missing(cur, consumers_id, "IOC", "caIOC_servers"): + added.append("PATHS.CONSUMERS.IOC") hpc_id = get_or_create_setting(cur, "hpc", "section", "HPC consumer names", consumers_id) - add_value_if_missing(cur, hpc_id, "BASE", "hpc") - add_value_if_missing(cur, hpc_id, "meta", "meta") - add_value_if_missing(cur, hpc_id, "analysis", "analysis") + if add_value_if_missing(cur, hpc_id, "BASE", "hpc"): + added.append("PATHS.CONSUMERS.hpc.BASE") + if add_value_if_missing(cur, hpc_id, "meta", "meta"): + added.append("PATHS.CONSUMERS.hpc.meta") + if add_value_if_missing(cur, hpc_id, "analysis", "analysis"): + added.append("PATHS.CONSUMERS.hpc.analysis") # ── APP_DATA > workflow > {meta, collector, analysis} ─────────────── # app_data_id = get_or_create_setting(cur, "APP_DATA", "root", "Application runtime data", None) @@ -138,8 +151,8 @@ def seed_defaults() -> None: # (authoritative when present and shaped like "{prefix}:Name:Value"), # otherwise fall back to DETECTOR_PREFIX. # Idempotent — profiles that already have IOC_PREFIX are skipped. - for row_id, raw in cur.execute( - "SELECT id, config_value FROM profile_configs " + for row_id, profile_id, raw in cur.execute( + "SELECT id, profile_id, config_value FROM profile_configs " "WHERE config_type='__toml__' AND config_key='__data__'" ).fetchall(): if not isinstance(raw, str): @@ -171,6 +184,7 @@ def seed_defaults() -> None: continue data["IOC_PREFIX"] = derived cur.execute("UPDATE profile_configs SET config_value=? WHERE id=?", (json.dumps(data), row_id)) + added.append(f"profile {profile_id}.IOC_PREFIX") conn.commit() except Exception: @@ -178,6 +192,7 @@ def seed_defaults() -> None: raise finally: conn.close() + return added if __name__ == "__main__": diff --git a/src/dashpva/workflow/workflow.py b/src/dashpva/workflow/workflow.py index dd424b55..8e483c42 100644 --- a/src/dashpva/workflow/workflow.py +++ b/src/dashpva/workflow/workflow.py @@ -72,6 +72,11 @@ _TREE_ORIGINAL_ROLE = Qt.UserRole # column 1: the untouched typed value _TREE_RENDERED_ROLE = Qt.UserRole + 1 # column 1: the text we rendered +_REFERENCE_PIXELS = 1_000_000 +_REFERENCE_IMAGE_BYTES = _REFERENCE_PIXELS * 2 +_REFERENCE_Q_BYTES = _REFERENCE_PIXELS * 3 * 8 +_MEMORY_CONFIRM_BYTES = 1024 ** 3 + class _OpaqueValue: """Carries a Python value through Qt item data untouched. @@ -376,8 +381,7 @@ def __init__(self, parent=None): super(Workflow, self).__init__(parent) uic.loadUi(str(pathlib.Path(__file__).parent / 'workflow.ui'), self) self.viewModeLayout.setAlignment(Qt.AlignTop) - # Pre-set sectionActive so QSS property selectors fire on first _on_config_source_changed - self.labelTomlHeader.setProperty("sectionActive", "true") + # Pre-set sectionActive so QSS property selectors fire on first update self.labelDatabaseHeader.setProperty("sectionActive", "true") self.treeWidgetConfig.setProperty("sectionActive", "true") self.buttonApplySave.setProperty("role", "info") @@ -402,13 +406,6 @@ def __init__(self, parent=None): self.buttonRunSimServer.clicked.connect(self.run_sim_server) self.buttonStopSimServer.clicked.connect(self.stop_sim_server) - # Config Tab — source radio (explicit group ensures mutual exclusivity) - self._config_source_group = QButtonGroup(self) - self._config_source_group.addButton(self.radioLegacyToml) - self._config_source_group.addButton(self.radioDatabase) - self.radioLegacyToml.toggled.connect(self._on_config_source_changed) - self.radioDatabase.toggled.connect(self._on_config_source_changed) - # Config Tab — view mode toggle (Profile vs Settings) self._view_mode_group = QButtonGroup(self) self._view_mode_group.addButton(self.radioViewProfile) @@ -416,8 +413,7 @@ def __init__(self, parent=None): self.radioViewProfile.toggled.connect(self._on_view_mode_changed) self.radioViewSettings.toggled.connect(self._on_view_mode_changed) - # Config Tab — file-based - self.buttonBrowseConfigUpload.clicked.connect(self.browse_config_upload) + # Config Tab — TOML import/export for database profiles self.buttonImportToml.clicked.connect(self.import_toml_to_tree) # Config Tab — db-based @@ -426,8 +422,6 @@ def __init__(self, parent=None): self.buttonDuplicateProfile.clicked.connect(self.duplicate_profile) self.comboBoxProfile.currentIndexChanged.connect(self._on_profile_selected) self.buttonRenameProfile.clicked.connect(self.edit_profile) - self.checkBoxDefaultProfile.toggled.connect(self._on_default_toggled) - self.checkBoxSelectedProfile.toggled.connect(self._on_selected_toggled) # Config Tab — always available self.buttonExportConfigToFile.clicked.connect(self.export_config_to_file) @@ -467,6 +461,8 @@ def __init__(self, parent=None): self.buttonRunAnalysisConsumer.clicked.connect(self.run_analysis_consumer) self.buttonStopAnalysisConsumer.clicked.connect(self.stop_analysis_consumer) + self._setup_memory_feedback() + # DB init / refresh buttons self.buttonInitDb.clicked.connect(self._init_db_and_recheck) self.buttonRefreshDb.clicked.connect(self._refresh_db) @@ -494,12 +490,10 @@ def __init__(self, parent=None): ) self.buttonClearOutputAnalysis.clicked.connect(self.textEditAnalysisConsumerOutput.clear) - # Check DB availability, populate combo, then switch to DB mode if possible + # Check DB availability and populate profiles self._check_db_availability() - self._on_config_source_changed() # sets Legacy mode UI state - self._refresh_profile_combo() # populate combo, pre-select right profile - if self._db_available: - self.radioDatabase.setChecked(True) # triggers _on_config_source_changed → load + self._refresh_profile_combo() + self._update_database_controls() self._populate_processor_file_combos() self._load_meta_assoc_last() self._load_collector_last() @@ -527,17 +521,15 @@ def _check_db_availability(self): self.labelDbStatus.setText('● DB Available') self.labelDbStatus.setStyleSheet(f'QLabel {{ color: {SUCCESS}; font-size: {FONT_CAPTION}; margin-left: 4px; }}') self.labelDbStatus.setToolTip('') - self.radioDatabase.setEnabled(True) self.buttonInitDb.setVisible(False) except Exception as e: self._db_available = False self.labelDbStatus.setText('● DB Unavailable') self.labelDbStatus.setStyleSheet(f'QLabel {{ color: {ERROR}; font-size: {FONT_CAPTION}; margin-left: 4px; }}') self.labelDbStatus.setToolTip(f'Error: {e}') - self.radioDatabase.setEnabled(False) self.buttonInitDb.setVisible(True) - # Fall back to legacy TOML if database radio was selected - self.radioLegacyToml.setChecked(True) + if hasattr(self, 'buttonImportToml'): + self._update_database_controls() def _init_db_and_recheck(self): from dashpva.database.db import create_tables, init_database @@ -557,13 +549,14 @@ def _init_db_and_recheck(self): def _refresh_db(self): """Re-check DB, reload the active view, and re-populate meta assoc fields.""" + active_profile_id = self.comboBoxProfile.currentData() self._check_db_availability() if self._db_available: - self._refresh_profile_combo() + self._refresh_profile_combo(preferred_profile_id=active_profile_id) if self.radioViewSettings.isChecked(): self._load_settings_tree() else: - self.load_profile_to_tree() + self._activate_current_profile() self._populate_processor_file_combos() self._load_meta_assoc_last() self._load_collector_last() @@ -640,29 +633,98 @@ def _on_analysis_processor_file_changed(self, processor_file): ) self.spinBoxNConsumersAnalysis.setToolTip("") + @staticmethod + def _format_memory(byte_count: int) -> str: + value = float(byte_count) + for unit in ('B', 'KiB', 'MiB', 'GiB', 'TiB'): + if value < 1024 or unit == 'TiB': + return f'{value:.1f} {unit}' + value /= 1024 + + @classmethod + def _frame_memory_text(cls, frame_count: int, rich_text: bool = False) -> str: + image = cls._format_memory(frame_count * _REFERENCE_IMAGE_BYTES) + image_q = cls._format_memory( + frame_count * (_REFERENCE_IMAGE_BYTES + _REFERENCE_Q_BYTES) + ) + if rich_text: + image = f'{image}' + image_q = f'{image_q}' + return f'Estimated for 1 MP: images {image}; with HKL {image_q}' + + def _setup_memory_feedback(self) -> None: + queue_tip = ( + 'Number of full input updates retained by the upstream PVA server for this ' + 'subscription. Larger values absorb short bursts but do not improve sustained ' + 'processing speed; they increase RAM use and latency.' + ) + cache_tip = ( + 'Number of full incoming frames retained by the collector while ordering producer ' + 'updates. This is separate from the server queue and profile frame cache.' + ) + controls = ( + (self.spinBoxServerQueueSizeAssociator, self.gridLayout, 7, queue_tip), + (self.spinBoxServerQueueSizeCollector, self.gridLayout_3, 9, queue_tip), + (self.spinBoxCollectorCacheSize, self.gridLayout_3, 10, cache_tip), + (self.spinBoxServerQueueSizeAnalysis, self.gridLayout_4, 7, queue_tip), + ) + self._memory_estimate_labels = [] + for spin_box, layout, row, tooltip in controls: + label = QtWidgets.QLabel() + label.setProperty('memoryEstimate', True) + label.setToolTip(tooltip) + spin_box.setToolTip(tooltip) + field = QtWidgets.QWidget() + field_layout = QtWidgets.QVBoxLayout(field) + field_layout.setContentsMargins(0, 0, 0, 0) + field_layout.setSpacing(2) + layout.removeWidget(spin_box) + field_layout.addWidget(spin_box) + field_layout.addWidget(label) + layout.addWidget(field, row, 1, 1, 2) + spin_box.valueChanged.connect( + lambda value, estimate_label=label: estimate_label.setText( + self._frame_memory_text(value, rich_text=True) + ) + ) + label.setText(self._frame_memory_text(spin_box.value(), rich_text=True)) + self._memory_estimate_labels.append(label) + + def _confirm_memory_risk(self, title: str, entries: list[tuple[str, int]]) -> bool: + risky = [(name, frames) for name, frames in entries + if frames * (_REFERENCE_IMAGE_BYTES + _REFERENCE_Q_BYTES) + >= _MEMORY_CONFIRM_BYTES] + if not risky: + return True + details = '\n'.join( + f'• {name}: {frames} frame(s) — {Workflow._frame_memory_text(frames)}' + for name, frames in risky + ) + reply = QMessageBox.warning( + self, + 'High Memory Setting', + f'{title} may retain a large amount of frame data.\n\n{details}\n\n' + 'Actual use depends on detector size, data type, compression, metadata, and the ' + 'number of subscriptions. Larger buffers do not improve sustained throughput.\n\n' + 'Start anyway?', + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + return reply == QMessageBox.Yes + # ------------------------------------------------------------------ # - # Config source toggle + # Database controls # ------------------------------------------------------------------ # - def _on_config_source_changed(self): - legacy = self.radioLegacyToml.isChecked() + def _update_database_controls(self): + db_active = self._db_available - # Legacy TOML controls - self.lineEditConfigUploadPath.setEnabled(legacy) - self.buttonBrowseConfigUpload.setEnabled(legacy) - - # Database controls (only enable if DB is also available) - db_active = not legacy and self._db_available - - # TOML import/export operate on the database — only active in DB mode self.buttonImportToml.setEnabled(db_active) self.buttonExportConfigToFile.setEnabled(db_active) self.labelProfile.setEnabled(db_active) self.comboBoxProfile.setEnabled(db_active) self.labelProfileDescription.setEnabled(db_active) self.lineEditProfileDescription.setEnabled(db_active) - self.checkBoxDefaultProfile.setEnabled(db_active) - self.checkBoxSelectedProfile.setEnabled(db_active) self.radioViewProfile.setEnabled(db_active) self.radioViewSettings.setEnabled(db_active) self.buttonReseed.setEnabled(db_active) @@ -672,11 +734,9 @@ def _on_config_source_changed(self): self.buttonDeleteProfile.setEnabled(db_active) self.buttonDuplicateProfile.setEnabled(db_active) - # Grey overlay on inactive section headers and config tree - state_toml = "true" if legacy else "false" + # Grey overlay when the database is unavailable state_db = "true" if db_active else "false" for widget, state in ( - (self.labelTomlHeader, state_toml), (self.labelDatabaseHeader, state_db), (self.treeWidgetConfig, state_db), ): @@ -685,51 +745,14 @@ def _on_config_source_changed(self): widget.style().polish(widget) widget.update() - # When first activating DB mode, auto-select the first profile as default+selected - if db_active: - self._auto_select_first_profile() if db_active and self.comboBoxProfile.currentIndex() >= 0: - self.load_profile_to_tree() + self._activate_current_profile() # ------------------------------------------------------------------ # # Config tab — profile combo # ------------------------------------------------------------------ # - def _auto_select_first_profile(self): - """When the DB is first activated, ensure a profile is marked default+selected. - If exactly one profile exists, always ensure it is default+selected.""" - if not self._db_available: - return - try: - profiles = self._db.get_all_profiles() - if not profiles: - return - if len(profiles) == 1: - first = profiles[0] - # Always ensure the sole profile is default and selected - needs_update = ( - not getattr(first, 'is_default', False) - or not getattr(first, 'is_selected', False) - ) - if needs_update: - self._db.set_default_profile(first.id) - self._db.set_selected_profile(first.id) - self._refresh_profile_combo() - idx = self.comboBoxProfile.findData(first.id) - if idx >= 0: - self.comboBoxProfile.setCurrentIndex(idx) - elif not self._db.any_default_exists(): - first = profiles[0] - self._db.set_default_profile(first.id) - self._db.set_selected_profile(first.id) - self._refresh_profile_combo() - idx = self.comboBoxProfile.findData(first.id) - if idx >= 0: - self.comboBoxProfile.setCurrentIndex(idx) - except Exception: - pass - - def _refresh_profile_combo(self): + def _refresh_profile_combo(self, preferred_profile_id=None): self.comboBoxProfile.blockSignals(True) self.comboBoxProfile.clear() if not self._db_available: @@ -747,11 +770,9 @@ def _refresh_profile_combo(self): target_idx = 0 for i, p in enumerate(profiles): self.comboBoxProfile.addItem(p.name, userData=p.id) - if getattr(p, 'is_selected', False): + if p.id == preferred_profile_id: target_idx = i - elif getattr(p, 'is_default', False) and not any( - getattr(profiles[j], 'is_selected', False) for j in range(i + 1) - ): + elif preferred_profile_id is None and getattr(p, 'is_selected', False): target_idx = i self.comboBoxProfile.setCurrentIndex(target_idx) except Exception: @@ -760,91 +781,67 @@ def _refresh_profile_combo(self): self.comboBoxProfile.blockSignals(False) # ------------------------------------------------------------------ # - # Config tab — legacy TOML + # Config tab — TOML profile import # ------------------------------------------------------------------ # - def browse_config_upload(self): - file_name, _ = QFileDialog.getOpenFileName( - self, 'Select Config File', app_settings.LAST_TOML_DIR, 'TOML Files (*.toml)' - ) - if file_name: - app_settings.LAST_TOML_DIR = str(pathlib.Path(file_name).parent) - self.lineEditConfigUploadPath.setText(file_name) - self.update_current_mode_label(file_name) - self._load_toml_into_tree(file_name) - def import_toml_to_tree(self): + if not self._db_available: + QMessageBox.warning(self, 'Database Unavailable', 'TOML files can only be imported into a database profile.') + return file_name, _ = QFileDialog.getOpenFileName( self, 'Import TOML Config', app_settings.LAST_TOML_DIR, 'TOML Files (*.toml)' ) if not file_name: return app_settings.LAST_TOML_DIR = str(pathlib.Path(file_name).parent) - self.lineEditConfigUploadPath.setText(file_name) - self.update_current_mode_label(file_name) - - # Always save to DB (if available) under the filename as profile name - if self._db_available: - profile_name = pathlib.Path(file_name).stem - try: - data = self.parse_toml(file_name) - # Always create a new profile with a unique name - candidate = profile_name - n = 1 - while self._db.get_profile_by_name(candidate) is not None: - candidate = f'{profile_name}-({n})' - n += 1 - profile = self._db.create_profile(candidate) - profile_name = candidate - self._db.import_toml_to_profile(profile.id, data) - # If this is the only profile, auto-mark it default+selected - if profile is not None and len(self._db.get_all_profiles()) == 1: - self._db.set_default_profile(profile.id) - self._db.set_selected_profile(profile.id) - self._refresh_profile_combo() - idx = self.comboBoxProfile.findText(profile_name) - if idx >= 0: - self.comboBoxProfile.setCurrentIndex(idx) - # Switch to DB mode so the tree loads from the saved profile - self.radioDatabase.setChecked(True) - # Explicitly populate tree — setChecked is a no-op if already checked, - # and setCurrentIndex won't fire if index didn't change (first import case) - if self.comboBoxProfile.currentIndex() >= 0: - self.load_profile_to_tree() - return - except Exception as e: - QMessageBox.critical(self, 'Error', f'Failed to save TOML to database:\n{e}') - # Fall through to load into tree without DB save - - # DB unavailable — just display in tree - self._load_toml_into_tree(file_name) - - def _load_toml_into_tree(self, path: str): + profile_name = pathlib.Path(file_name).stem try: - data = self.parse_toml(path) + data = self.parse_toml(file_name) + candidate = profile_name + suffix = 1 + while self._db.get_profile_by_name(candidate) is not None: + candidate = f'{profile_name}-({suffix})' + suffix += 1 + profile = self._db.create_profile(candidate) + self._db.import_toml_to_profile(profile.id, data) + self._refresh_profile_combo() + idx = self.comboBoxProfile.findData(profile.id) + if idx >= 0: + self.comboBoxProfile.blockSignals(True) + self.comboBoxProfile.setCurrentIndex(idx) + self.comboBoxProfile.blockSignals(False) + self._activate_current_profile() except Exception as e: - QMessageBox.critical(self, 'Error', f'Failed to parse TOML:\n{e}') - return - self.treeWidgetConfig.clear() - self._populate_tree_node(data, parent=None) - self._store_snapshot() - # Update settings module so the rest of the app uses this TOML - try: - app_settings.set_locator(path) - app_settings.reload() - except Exception: - pass + QMessageBox.critical(self, 'Error', f'Failed to import TOML into the database:\n{e}') # ------------------------------------------------------------------ # # Config tab — database profile # ------------------------------------------------------------------ # def _on_profile_selected(self, index): - """Auto-load the selected profile into the tree when DB mode is active.""" - if not self.radioDatabase.isChecked() or not self._db_available: + """Make the dropdown profile active and load it into the editor.""" + if not self._db_available: return if index < 0: return + self._activate_current_profile() + + def _activate_current_profile(self): + idx = self.comboBoxProfile.currentIndex() + if idx < 0 or not self._db_available: + return + profile_id = self.comboBoxProfile.itemData(idx) + if profile_id is None: + return + try: + if not self._db.set_selected_profile(profile_id): + raise RuntimeError('profile could not be selected') + app_settings.set_locator(profile_id) + app_settings.reload() + self._sync_associator_metadata() + except Exception as e: + QMessageBox.critical(self, 'Profile Error', f'Failed to activate profile:\n{e}') + return self.load_profile_to_tree() def load_profile_to_tree(self): @@ -857,25 +854,6 @@ def load_profile_to_tree(self): profile = self._db.get_profile_by_id(profile_id) desc = getattr(profile, 'description', '') or '' self.lineEditProfileDescription.setText(desc) - # Update checkboxes without triggering their toggle slots - self.checkBoxDefaultProfile.blockSignals(True) - self.checkBoxSelectedProfile.blockSignals(True) - self.checkBoxDefaultProfile.setChecked(bool(getattr(profile, 'is_default', False))) - self.checkBoxSelectedProfile.setChecked(bool(getattr(profile, 'is_selected', False))) - self.checkBoxDefaultProfile.blockSignals(False) - self.checkBoxSelectedProfile.blockSignals(False) - # Show tooltips when this is the only profile - is_sole = len(self._db.get_all_profiles()) == 1 - if is_sole: - self.checkBoxDefaultProfile.setToolTip( - 'Only profile — automatically set as default' - ) - self.checkBoxSelectedProfile.setToolTip( - 'Only profile — automatically set as selected' - ) - else: - self.checkBoxDefaultProfile.setToolTip('') - self.checkBoxSelectedProfile.setToolTip('') data = self._db.export_profile_to_toml(profile_id) except Exception as e: QMessageBox.critical(self, 'Error', f'Failed to load profile:\n{e}') @@ -885,38 +863,6 @@ def load_profile_to_tree(self): self._populate_tree_node(data, parent=None) self._store_snapshot() self.treeWidgetConfig.blockSignals(False) - # Viewing a profile must not activate it. The active profile is whatever - # is marked selected in the database (checkBoxSelectedProfile); setting - # the locator here made merely browsing the combo box retarget the rest - # of the app -- HKL Setup then opened the profile being looked at - # instead of the selected one. - - def _on_default_toggled(self, checked: bool): - idx = self.comboBoxProfile.currentIndex() - if idx < 0 or not self._db_available: - return - profile_id = self.comboBoxProfile.itemData(idx) - try: - if checked: - self._db.set_default_profile(profile_id) - else: - self._db.unset_default_profile(profile_id) - except Exception as e: - QMessageBox.critical(self, 'Error', f'Failed to update default:\n{e}') - - def _on_selected_toggled(self, checked: bool): - idx = self.comboBoxProfile.currentIndex() - if idx < 0 or not self._db_available: - return - profile_id = self.comboBoxProfile.itemData(idx) - try: - if checked: - self._db.set_selected_profile(profile_id) - else: - self._db.clear_selected_profiles() - except Exception as e: - QMessageBox.critical(self, 'Error', f'Failed to update selected:\n{e}') - # ------------------------------------------------------------------ # # View mode toggle (Profile / Settings) # ------------------------------------------------------------------ # @@ -924,7 +870,6 @@ def _on_selected_toggled(self, checked: bool): def _set_profile_widgets_visible(self, visible: bool): for w in ( self.labelProfile, self.comboBoxProfile, - self.checkBoxDefaultProfile, self.checkBoxSelectedProfile, self.labelProfileDescription, self.lineEditProfileDescription, self.buttonAddProfile, self.buttonDeleteProfile, self.buttonRenameProfile, self.buttonDuplicateProfile, @@ -937,7 +882,7 @@ def _on_view_mode_changed(self): self._set_profile_widgets_visible(not settings_mode) if settings_mode: self._load_settings_tree() - elif self.radioDatabase.isChecked() and self._db_available: + elif self._db_available: self._restore_two_column_tree() self.treeWidgetConfig.setEditTriggers(QAbstractItemView.DoubleClicked) self.load_profile_to_tree() @@ -1046,18 +991,13 @@ def add_profile(self): self._db.import_toml_to_profile(profile.id, defaults) except Exception: pass # don't block profile creation if seeding fails - # If this is the only profile, auto-mark it default+selected before refreshing - if profile is not None and len(self._db.get_all_profiles()) == 1: - self._db.set_default_profile(profile.id) - self._db.set_selected_profile(profile.id) self._refresh_profile_combo() idx = self.comboBoxProfile.findText(name) if idx >= 0: + self.comboBoxProfile.blockSignals(True) self.comboBoxProfile.setCurrentIndex(idx) - # setCurrentIndex won't fire if index didn't change (first-profile case), - # so explicitly load the tree here - if self.comboBoxProfile.currentIndex() >= 0: - self.load_profile_to_tree() + self.comboBoxProfile.blockSignals(False) + self._activate_current_profile() except Exception as e: QMessageBox.critical(self, 'Error', f'Failed to create profile:\n{e}') @@ -1080,7 +1020,11 @@ def delete_profile(self): self.treeWidgetConfig.clear() self.lineEditProfileDescription.clear() self._refresh_profile_combo() - self.load_profile_to_tree() + if self.comboBoxProfile.currentIndex() >= 0: + self._activate_current_profile() + else: + app_settings.set_locator(None) + app_settings.reload() except Exception as e: QMessageBox.critical(self, 'Error', f'Failed to delete profile:\n{e}') @@ -1105,7 +1049,10 @@ def duplicate_profile(self): self._refresh_profile_combo() new_idx = self.comboBoxProfile.findText(new_name) if new_idx >= 0: + self.comboBoxProfile.blockSignals(True) self.comboBoxProfile.setCurrentIndex(new_idx) + self.comboBoxProfile.blockSignals(False) + self._activate_current_profile() except Exception as e: QMessageBox.critical(self, 'Error', f'Failed to duplicate profile:\n{e}') @@ -1685,10 +1632,8 @@ def _update_config_action_state(self): def _on_clear_changes(self): if self.radioViewSettings.isChecked(): self._load_settings_tree() - elif self.radioDatabase.isChecked() and self._db_available: + elif self._db_available: self.load_profile_to_tree() - elif app_settings.TOML_FILE: - self._load_toml_into_tree(app_settings.TOML_FILE) self._structural_changed = False self._update_config_action_state() @@ -1799,6 +1744,7 @@ def _drop(idx): w = table.cellWidget(idx, 3) if w is not None: w.setEnabled(False) + _update_cache_notice() for r, (kind, key, old, new) in enumerate(rows): key_item = QTableWidgetItem( @@ -1835,6 +1781,44 @@ def _drop(idx): table.setCellWidget(r, 3, btn) layout.addWidget(table) + + cache_paths = { + 'CACHE_OPTIONS.ALIGNMENT.MAX_CACHE_SIZE', + 'CACHE_OPTIONS.SCAN.MAX_CACHE_SIZE', + } + cache_rows = [ + index for index, (_kind, key, _old, _new) in enumerate(rows) + if key in cache_paths + ] + cache_notice = QtWidgets.QLabel() + cache_notice.setWordWrap(True) + + def _update_cache_notice(): + estimates = [] + for index in cache_rows: + if dropped_flags[index]: + continue + cell = table.item(index, 2) + try: + frame_count = int(cell.text()) + except (AttributeError, TypeError, ValueError): + continue + estimates.append( + f'{rows[index][1]} = {frame_count}: ' + f'{self._frame_memory_text(frame_count)}' + ) + cache_notice.setText( + 'Frame-cache memory estimate (Save HDF5/Post-scan; not needed for ordinary ' + 'live viewing):\n' + '\n'.join(estimates) + if estimates else '' + ) + cache_notice.setVisible(bool(estimates)) + + if cache_rows: + table.itemChanged.connect(lambda _item: _update_cache_notice()) + layout.addWidget(cache_notice) + _update_cache_notice() + btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) btns.accepted.connect(dlg.accept) btns.rejected.connect(dlg.reject) @@ -1882,33 +1866,6 @@ def _apply_dialog_decisions(self, kept, dropped): # Apply / Save # ------------------------------------------------------------------ # - def _save_toml_from_tree(self) -> bool: - path = app_settings.TOML_FILE - if not path: - QMessageBox.critical(self, 'Save Error', 'No TOML file loaded. Load a config file first.') - return False - data = self._extract_tree_to_dict() - try: - resolve_profile_config(data) - except Exception as e: - QMessageBox.critical( - self, 'Save Error', f'Refusing to save an unloadable profile:\n{e}' - ) - return False - try: - with open(path, 'w') as f: - toml.dump(data, f) - except Exception as e: - QMessageBox.critical(self, 'Save Error', f'Failed to write TOML:\n{e}') - return False - try: - app_settings.set_locator(path) - app_settings.reload() - self._sync_associator_metadata() - except Exception: - pass - return True - def _on_apply_save(self): before = dict(self._tree_snapshot) after = self._snapshot_tree() @@ -1921,9 +1878,6 @@ def _on_apply_save(self): self._apply_dialog_decisions(kept, dropped) if self.radioViewSettings.isChecked(): self._save_settings_from_tree() - elif self.radioLegacyToml.isChecked(): - if not self._save_toml_from_tree(): - return else: self._save_tree_to_active_profile() self._structural_changed = False @@ -1931,19 +1885,20 @@ def _on_apply_save(self): def _on_reseed(self): reply = QMessageBox.question( - self, 'Reseed', 'Add missing default values to settings and the current profile?', + self, 'Add Missing Defaults', + 'Add missing default values to settings and the current profile?', QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return - added = [0] + profile_additions = [] try: from dashpva.scripts.seed_settings_defaults_sql import seed_defaults - seed_defaults() + settings_additions = seed_defaults() except Exception as e: - QMessageBox.critical(self, 'Reseed', f'Settings reseed failed:\n{e}') + QMessageBox.critical(self, 'Add Missing Defaults', f'Settings update failed:\n{e}') return - if self.radioDatabase.isChecked() and self._db_available and not self.radioViewSettings.isChecked(): + if self._db_available and not self.radioViewSettings.isChecked(): _sample = pathlib.Path(__file__).resolve().parents[3] / 'pv_configs' / 'sample_config.toml' if _sample.exists(): try: @@ -1955,7 +1910,7 @@ def _on_reseed(self): if _pid is not None else self._extract_tree_to_dict() ) - merged = self._deep_merge(defaults, current, added) + merged = self._deep_merge(defaults, current, profile_additions) # Save the correctly-typed merged dict directly -- routing # it through the tree first (str() to display, then # re-parsed back) is exactly the round-trip that turns @@ -1968,21 +1923,33 @@ def _on_reseed(self): self._populate_tree_node(merged, parent=None) self.treeWidgetConfig.blockSignals(False) except Exception as e: - QMessageBox.critical(self, 'Reseed', f'Profile reseed failed:\n{e}') + QMessageBox.critical(self, 'Add Missing Defaults', f'Profile update failed:\n{e}') return elif self.radioViewSettings.isChecked(): self.load_profile_to_tree() - msg = f'{added[0]} missing profile value(s) added.' if added[0] else 'No missing profile values found.' - QMessageBox.information(self, 'Reseed', f'Settings defaults restored.\n{msg}') + additions = [*(f'Setting: {path}' for path in settings_additions), + *(f'Profile: {path}' for path in profile_additions)] + box = QMessageBox(self) + box.setIcon(QMessageBox.Information) + box.setWindowTitle('Add Missing Defaults') + if additions: + box.setText(f'Added {len(additions)} missing default value(s).') + box.setInformativeText('Select Show Details to see every value that was added.') + box.setDetailedText('\n'.join(additions)) + else: + box.setText('No missing default values were found.') + box.setInformativeText('Existing settings and profile values were left unchanged.') + box.exec_() - def _deep_merge(self, defaults: dict, current: dict, added: list) -> dict: + def _deep_merge(self, defaults: dict, current: dict, added: list, prefix: str = '') -> dict: result = dict(current) for key, val in defaults.items(): + path = f'{prefix}.{key}' if prefix else key if key not in result: result[key] = val - added[0] += 1 + added.append(path) elif isinstance(val, dict) and isinstance(result[key], dict): - result[key] = self._deep_merge(val, result[key], added) + result[key] = self._deep_merge(val, result[key], added, path) return result def _save_settings_from_tree(self): @@ -2080,7 +2047,7 @@ def _save_settings_view_item(self, item, column): def _save_tree_to_active_profile(self): """Persist current tree contents to the active DB profile. No-op in TOML mode or Settings view.""" - if not (self.radioDatabase.isChecked() and self._db_available): + if not self._db_available: return if self.radioViewSettings.isChecked(): return @@ -2264,19 +2231,6 @@ def parse_toml(self, path) -> dict: with open(path, 'r') as f: return toml.load(f) - def update_current_mode_label(self, path: str) -> None: - text = '(none)' - try: - toml_data = self.parse_toml(path) - mode = toml_data.get('CACHE_OPTIONS', {}).get('CACHING_MODE', '') - text = mode if mode else '(none)' - except Exception: - text = '(error)' - try: - self.labelCurrentModeValue.setText(text) - except Exception: - pass - # ------------------------------------------------------------------ # # Sim Server # ------------------------------------------------------------------ # @@ -2699,6 +2653,16 @@ def run_associator_consumers(self): self.logger.warning('Start Metadata Associator ignored — already running') QtWidgets.QMessageBox.warning(self, 'Warning', 'Associator Consumers are already running.') return + queue_frames = ( + self.spinBoxServerQueueSizeAssociator.value() + * self.spinBoxNConsumersAssociator.value() + ) + if not Workflow._confirm_memory_risk( + self, + 'Starting the Metadata Associator', + [('Server queues across consumers', queue_frames)], + ): + return # Reload before building anything below, so a profile switched (or # edited via the separate HKL Setup process) since the last reload is @@ -3067,6 +3031,17 @@ def run_collector(self): if 'collector' in self.processes: QtWidgets.QMessageBox.warning(self, 'Warning', 'Collector is already running.') return + producer_count = max(1, int(self.lineEditProducerIdList.text())) + if not Workflow._confirm_memory_risk( + self, + 'Starting the Collector', + [ + ('Server queues across producers', + self.spinBoxServerQueueSizeCollector.value() * producer_count), + ('Collector cache', self.spinBoxCollectorCacheSize.value()), + ], + ): + return self._save_collector_last() producer_id_list = [str(i) for i in range(1, int(self.lineEditProducerIdList.text()) + 1)] @@ -3133,6 +3108,16 @@ def run_analysis_consumer(self): if 'analysis_consumer' in self.processes: QtWidgets.QMessageBox.warning(self, 'Warning', 'Analysis Consumer is already running.') return + queue_frames = ( + self.spinBoxServerQueueSizeAnalysis.value() + * self.spinBoxNConsumersAnalysis.value() + ) + if not Workflow._confirm_memory_risk( + self, + 'Starting the Analysis Consumer', + [('Server queues across consumers', queue_frames)], + ): + return self._on_analysis_processor_file_changed( self.comboBoxProcessorFileAnalysis.currentText() ) diff --git a/src/dashpva/workflow/workflow.ui b/src/dashpva/workflow/workflow.ui index 9f36442e..6b74cdcc 100644 --- a/src/dashpva/workflow/workflow.ui +++ b/src/dashpva/workflow/workflow.ui @@ -300,33 +300,9 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - + - - - - Source: - - - - - - - Legacy TOML - - - true - - - - - - - Database - - - @@ -347,69 +323,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - - - - - Legacy TOML - - - - - - - - - - - Config File Path - - - - - - - - - - - - Browse - - - - - - - - - Current Mode - - - - - - - - - - - - - - - - - - - QFrame::HLine - - - QFrame::Sunken - - - - - + @@ -468,20 +382,20 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ↻ Refresh + Reload Profile - Reload the database and refresh the current view + Reload the selected profile from the database, including external IOC RSM Parameter edits - Reseed + Add Missing Defaults - Add missing default values to settings and the current profile — never overwrites existing values + Add missing default settings and profile values without overwriting existing values @@ -532,26 +446,6 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - - - - false - - - Default - - - - - - - false - - - Selected - - - @@ -580,7 +474,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - + @@ -621,39 +515,31 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - - - - Qt::Horizontal - - - - + - + Import TOML + + Create a database profile from a TOML file + - + - Export to File + Export TOML - - - - - - Qt::Horizontal + + Export the selected database profile to a TOML file - + @@ -1039,7 +925,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 10000 - 1000 + 10 diff --git a/tests/unit/test_seed_defaults.py b/tests/unit/test_seed_defaults.py index 95c6e773..ae44543c 100644 --- a/tests/unit/test_seed_defaults.py +++ b/tests/unit/test_seed_defaults.py @@ -83,7 +83,7 @@ def seeded_db(tmp_path, monkeypatch): monkeypatch.setattr(seed_mod, "DB_PATH", str(db_file)) def run_seed(): - seed_mod.seed_defaults() + return seed_mod.seed_defaults() return db_file, run_seed @@ -118,17 +118,18 @@ class TestSeedDefaultsAppRows: def test_seeds_expected_top_level_settings(self, seeded_db): db_file, run_seed = seeded_db - run_seed() + added = run_seed() conn = sqlite3.connect(db_file) names = {row[0] for row in conn.execute("SELECT name FROM settings WHERE parent_id IS NULL")} conn.close() assert {"BEAMLINE_NAME", "PATHS", "APP_DATA"} <= names + assert "PATHS.LOG.BASE" in added def test_idempotent_no_duplicate_rows(self, seeded_db): db_file, run_seed = seeded_db run_seed() - run_seed() - run_seed() + assert run_seed() == [] + assert run_seed() == [] conn = sqlite3.connect(db_file) # No duplicates: each (name, parent_id) pair should appear at most once. dupes = conn.execute( diff --git a/tests/unit/test_workflow_config_tree.py b/tests/unit/test_workflow_config_tree.py index d701e639..3e545cd0 100644 --- a/tests/unit/test_workflow_config_tree.py +++ b/tests/unit/test_workflow_config_tree.py @@ -42,14 +42,16 @@ """ import pathlib +from types import SimpleNamespace import pytest import toml pytest.importorskip("PyQt5.QtWidgets") -from PyQt5.QtWidgets import QApplication, QTreeWidget # noqa: E402 +from PyQt5.QtWidgets import QApplication, QComboBox, QTreeWidget # noqa: E402 +import dashpva.workflow.workflow as workflow_module # noqa: E402 from dashpva.utils.config.resolver import resolve_profile_config # noqa: E402 from dashpva.workflow.workflow import Workflow # noqa: E402 @@ -210,3 +212,68 @@ def test_list_items_are_not_editable(self, tree): tree.round_trip({"AXES": [{"LABEL": "Mu"}]}) item = tree.treeWidgetConfig.topLevelItem(0) assert not (item.flags() & Qt.ItemIsEditable) + + +def test_profile_dropdown_activates_runtime_profile(qapp, monkeypatch): + calls = [] + + class Database: + def set_selected_profile(self, profile_id): + calls.append(("selected", profile_id)) + return True + + workflow = Workflow.__new__(Workflow) + workflow._db_available = True + workflow._db = Database() + workflow.comboBoxProfile = QComboBox() + workflow.comboBoxProfile.addItem("first", 11) + workflow.comboBoxProfile.addItem("second", 22) + workflow.comboBoxProfile.setCurrentIndex(1) + workflow.processes = {} + workflow.load_profile_to_tree = lambda: calls.append(("loaded", 22)) + + monkeypatch.setattr(workflow_module.app_settings, "set_locator", lambda value: calls.append(("locator", value))) + monkeypatch.setattr(workflow_module.app_settings, "reload", lambda: calls.append(("reload", None))) + + workflow._activate_current_profile() + + assert calls == [ + ("selected", 22), + ("locator", 22), + ("reload", None), + ("loaded", 22), + ] + + +def test_refresh_profile_combo_preserves_displayed_profile(qapp): + class Database: + @staticmethod + def get_all_profiles(): + return [ + SimpleNamespace(id=11, name="first", is_selected=True), + SimpleNamespace(id=22, name="editing", is_selected=False), + ] + + workflow = Workflow.__new__(Workflow) + workflow._db_available = True + workflow._db = Database() + workflow.comboBoxProfile = QComboBox() + + workflow._refresh_profile_combo(preferred_profile_id=22) + + assert workflow.comboBoxProfile.currentData() == 22 + assert workflow.comboBoxProfile.currentText() == "editing" + + +def test_deep_merge_reports_added_profile_paths(): + additions = [] + workflow = Workflow.__new__(Workflow) + + merged = workflow._deep_merge( + {"HKL": {"SPEC": {"ENERGY": 1}, "DETECTOR": {"DISTANCE": 2}}}, + {"HKL": {"SPEC": {}, "EXISTING": True}}, + additions, + ) + + assert merged["HKL"]["SPEC"]["ENERGY"] == 1 + assert additions == ["HKL.SPEC.ENERGY", "HKL.DETECTOR"] diff --git a/tests/unit/test_workflow_memory_estimates.py b/tests/unit/test_workflow_memory_estimates.py new file mode 100644 index 00000000..a71379c9 --- /dev/null +++ b/tests/unit/test_workflow_memory_estimates.py @@ -0,0 +1,61 @@ +# Copyright © 2026, UChicago Argonne, LLC +# All Rights Reserved +# Software Name: DashPVA +# By: Argonne National Laboratory +# +# BSD OPEN SOURCE LICENSE +# +# Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. +# +# ****************************************************************************************************** +# DISCLAIMER +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# ****************************************************************************************************** + + +"""Workflow queue/cache memory estimates and the high-memory confirmation.""" + +from dashpva.workflow import workflow +from dashpva.workflow.workflow import Workflow + + +def test_frame_memory_text_scales_with_frame_count(): + one = Workflow._frame_memory_text(1) + assert one.startswith("Estimated for 1 MP: images") + assert "with HKL" in one + assert "1.9 MiB" in one + assert "24.8 MiB" in Workflow._frame_memory_text(1) + assert "GiB" in Workflow._frame_memory_text(1000) + + +def test_frame_memory_rich_text_bolds_sizes(): + text = Workflow._frame_memory_text(1, rich_text=True) + assert "images 1.9 MiB" in text + assert "with HKL 24.8 MiB" in text + + +def test_small_buffers_start_without_asking(monkeypatch): + asked = [] + monkeypatch.setattr(workflow.QMessageBox, "warning", lambda *a, **k: asked.append(a)) + assert Workflow._confirm_memory_risk(None, "Start", [("Server queue", 10)]) + assert asked == [] + + +def test_large_buffers_ask_and_respect_no(monkeypatch): + asked = [] + + def _warning(*args, **_kwargs): + asked.append((args[1], args[2])) + return workflow.QMessageBox.No + + monkeypatch.setattr(workflow.QMessageBox, "warning", _warning) + assert not Workflow._confirm_memory_risk(None, "Starting the Analysis Consumer", + [("Server queue", 1000)]) + title, text = asked[0] + assert title == "High Memory Setting" + assert "1000 frame(s)" in text