From c2fa001a8247aee2ff6d4c6c797a7cfb8ba7ec6b Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Wed, 13 May 2026 15:46:54 -0600 Subject: [PATCH 1/7] Fix project-load progress updates to run on UI thread --- src/saxshell/saxs/ui/main_window.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/saxshell/saxs/ui/main_window.py b/src/saxshell/saxs/ui/main_window.py index 5e04d24..5ed6417 100644 --- a/src/saxshell/saxs/ui/main_window.py +++ b/src/saxshell/saxs/ui/main_window.py @@ -1067,6 +1067,8 @@ class SAXSMainWindow(QMainWindow): """Main Qt window for SAXS project setup, prefit, and DREAM refinement.""" + project_load_progress_received = Signal(int, int, str, str) + DREAM_REFRESH_DELAY_MS = 75 DREAM_REFRESH_STYLE = 1 DREAM_REFRESH_VIOLIN = 2 @@ -1123,6 +1125,9 @@ def __init__( self._load_console_autoscroll_setting() ) self._ui_scale = 1.0 + self.project_load_progress_received.connect( + self._on_project_load_progress_received + ) self._base_font_point_size = self._resolve_base_font_point_size() self._scale_shortcuts: list[QShortcut] = [] self._child_tool_windows: list[object] = [] @@ -2043,11 +2048,11 @@ def on_progress( if progress_callback is None: return del total - self._update_project_load_progress( + self.project_load_progress_received.emit( processed, PROJECT_LOAD_TOTAL_STEPS, message, - log_message=message, + message, ) def on_finished(task_name: str, worker_result: object) -> None: @@ -3275,6 +3280,21 @@ def _begin_project_load_progress( self.statusBar().showMessage(message) QApplication.processEvents() + @Slot(int, int, str, str) + def _on_project_load_progress_received( + self, + processed: int, + total_steps: int, + message: str, + log_message: str, + ) -> None: + self._update_project_load_progress( + processed, + total_steps, + message, + log_message=log_message or None, + ) + def _update_project_load_progress( self, processed: int, From 90df724de1b98879f7be0133caf1273c7a47bf7c Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Thu, 14 May 2026 15:31:00 -0600 Subject: [PATCH 2/7] Constrain experimental data header dialog geometry Make ExperimentalDataHeaderDialog size itself relative to the available screen, cap max size, and relax widgets that could inflate minimum width/height (long file path label and preview box wrapping/height). Also add a regression test for long-path/long-line content and screen constraints. --- .../saxs/ui/experimental_data_loader.py | 27 ++++++++++++--- tests/test_saxs_ui.py | 33 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/saxshell/saxs/ui/experimental_data_loader.py b/src/saxshell/saxs/ui/experimental_data_loader.py index 7477d60..9affc9b 100644 --- a/src/saxshell/saxs/ui/experimental_data_loader.py +++ b/src/saxshell/saxs/ui/experimental_data_loader.py @@ -5,6 +5,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QApplication, QComboBox, QDialog, QDialogButtonBox, @@ -75,7 +76,21 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle("Check Experimental Data File") - self.resize(900, 720) + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + width = max(900, int(available.width() * 0.85)) + height = max(600, int(available.height() * 0.85)) + self.resize( + min(width, available.width()), + min(height, available.height()), + ) + self.setMaximumSize( + int(available.width() * 0.95), + int(available.height() * 0.95), + ) + else: + self.resize(900, 600) root = QVBoxLayout(self) intro_label = QLabel( @@ -91,6 +106,8 @@ def _build_ui(self) -> None: self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) + self.file_label.setWordWrap(True) + self.file_label.setMinimumWidth(0) form.addRow("File", self.file_label) self.header_rows_spin = QSpinBox() @@ -110,9 +127,11 @@ def _build_ui(self) -> None: self.preview_box = QPlainTextEdit() self.preview_box.setReadOnly(True) - self.preview_box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.preview_box.setMinimumHeight(420) - root.addWidget(self.preview_box) + self.preview_box.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + ) + self.preview_box.setMinimumHeight(250) + root.addWidget(self.preview_box, stretch=1) self.status_label = QLabel( "Adjust the header length and selected columns, then click Load File." diff --git a/tests/test_saxs_ui.py b/tests/test_saxs_ui.py index c19c535..fa39c52 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -32,6 +32,7 @@ QInputDialog, QLabel, QMessageBox, + QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, @@ -13294,6 +13295,38 @@ def test_experimental_data_header_dialog_allows_manual_column_selection( assert np.allclose(dialog.accepted_summary.errors, [0.1, 0.2]) +def test_experimental_data_header_dialog_screen_constrained_with_long_content( + qapp, tmp_path +): + del qapp + data_path = tmp_path / ("onedrive_" + ("very_long_segment_" * 30) + ".txt") + data_path.write_text( + "q intensity sigma\n" + + ("0.05 " + ("1234567890" * 80) + " 0.1\n") + + "0.10 9.5 0.2\n", + encoding="utf-8", + ) + + dialog = ExperimentalDataHeaderDialog(data_path) + + assert dialog.file_label.wordWrap() is True + assert ( + dialog.preview_box.lineWrapMode() + == QPlainTextEdit.LineWrapMode.WidgetWidth + ) + assert dialog.preview_box.minimumHeight() == 250 + + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + max_size = dialog.maximumSize() + min_size = dialog.minimumSize() + assert max_size.width() <= int(available.width() * 0.95) + assert max_size.height() <= int(available.height() * 0.95) + assert min_size.width() <= available.width() + assert min_size.height() <= available.height() + + def test_project_setup_preview_updates_with_experimental_q_range( qapp, tmp_path ): From 567bb4870a9f3a93947196e1e8005269be36fa8e Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Thu, 14 May 2026 15:43:47 -0600 Subject: [PATCH 3/7] Bound experimental data header dialog to screen size Make ExperimentalDataHeaderDialog size-aware using available screen geometry, wrap long file paths and preview text, and lower preview minimum height so long content cannot force oversized minimum dimensions. Add a regression test that validates the safer wrapping behavior and screen-bounded maximum size. --- .../saxs/ui/experimental_data_loader.py | 24 ++++++++++--- tests/test_saxs_ui.py | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/saxshell/saxs/ui/experimental_data_loader.py b/src/saxshell/saxs/ui/experimental_data_loader.py index 7477d60..c99f25f 100644 --- a/src/saxshell/saxs/ui/experimental_data_loader.py +++ b/src/saxshell/saxs/ui/experimental_data_loader.py @@ -5,6 +5,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QApplication, QComboBox, QDialog, QDialogButtonBox, @@ -75,7 +76,18 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle("Check Experimental Data File") - self.resize(900, 720) + screen = QApplication.primaryScreen() + if screen is None: + self.resize(900, 600) + else: + available = screen.availableGeometry() + target_width = min(900, int(available.width() * 0.85)) + target_height = min(600, int(available.height() * 0.85)) + self.resize(max(480, target_width), max(360, target_height)) + self.setMaximumSize( + max(520, int(available.width() * 0.95)), + max(420, int(available.height() * 0.95)), + ) root = QVBoxLayout(self) intro_label = QLabel( @@ -91,6 +103,8 @@ def _build_ui(self) -> None: self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) + self.file_label.setWordWrap(True) + self.file_label.setMinimumWidth(0) form.addRow("File", self.file_label) self.header_rows_spin = QSpinBox() @@ -110,9 +124,11 @@ def _build_ui(self) -> None: self.preview_box = QPlainTextEdit() self.preview_box.setReadOnly(True) - self.preview_box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.preview_box.setMinimumHeight(420) - root.addWidget(self.preview_box) + self.preview_box.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + ) + self.preview_box.setMinimumHeight(250) + root.addWidget(self.preview_box, stretch=1) self.status_label = QLabel( "Adjust the header length and selected columns, then click Load File." diff --git a/tests/test_saxs_ui.py b/tests/test_saxs_ui.py index c19c535..2c5e466 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -13294,6 +13294,41 @@ def test_experimental_data_header_dialog_allows_manual_column_selection( assert np.allclose(dialog.accepted_summary.errors, [0.1, 0.2]) +def test_experimental_data_header_dialog_geometry_is_screen_bounded( + qapp, tmp_path +): + del qapp + data_path = tmp_path / "exp_long_lines.txt" + long_path_segment = "OneDrive - UCB-O365" * 20 + data_path.write_text( + ( + f"{long_path_segment}\n" + f"{'q intensity error ' * 80}\n" + f"{'0.05 10.0 0.1 ' * 80}\n" + ), + encoding="utf-8", + ) + + dialog = ExperimentalDataHeaderDialog(data_path) + + assert dialog.file_label.wordWrap() + assert dialog.file_label.minimumWidth() == 0 + assert ( + dialog.preview_box.lineWrapMode() + == dialog.preview_box.LineWrapMode.WidgetWidth + ) + assert dialog.preview_box.minimumHeight() == 250 + + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + max_size = dialog.maximumSize() + assert max_size.width() <= available.width() + assert max_size.height() <= available.height() + assert dialog.minimumWidth() <= available.width() + assert dialog.minimumHeight() <= available.height() + + def test_project_setup_preview_updates_with_experimental_q_range( qapp, tmp_path ): From 2ab9de9e54e8d07a3650a58abe0e4d6531150e17 Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Thu, 14 May 2026 16:03:52 -0600 Subject: [PATCH 4/7] Fix experimental header parsing and dialog width inflation Constrain header dialog child size hints so long labels and combo entries do not inflate minimum tracking size on Windows. Improve experimental header/column detection to ignore metadata comment lines while still accepting commented real headers like '# q I(q)'. Add regression coverage for metadata comment parsing and dialog size policies. --- src/saxshell/saxs/project_manager/project.py | 57 ++++++++++++- .../saxs/ui/experimental_data_loader.py | 47 ++++++++++- tests/test_saxs_ui.py | 82 +++++++++++++++++++ 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/saxshell/saxs/project_manager/project.py b/src/saxshell/saxs/project_manager/project.py index 487679d..fc81695 100644 --- a/src/saxshell/saxs/project_manager/project.py +++ b/src/saxshell/saxs/project_manager/project.py @@ -5140,7 +5140,14 @@ def _guess_experimental_header_rows(file_path: Path) -> int: if not stripped: header_rows += 1 continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + header_rows += 1 + continue + candidate_line = _strip_comment_prefix(stripped) + if not candidate_line: + header_rows += 1 + continue + tokens = _split_experimental_line(candidate_line) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return header_rows header_rows += 1 @@ -5155,10 +5162,15 @@ def _read_experimental_column_names( encoding="utf-8", errors="replace" ).splitlines() if header_rows > 0 and header_rows <= len(lines): + header_line = lines[header_rows - 1].strip() header_tokens = _split_experimental_line( - lines[header_rows - 1].lstrip("#").strip() + _strip_comment_prefix(header_line) ) - if header_tokens and not _tokens_look_numeric(header_tokens): + if ( + header_tokens + and not _tokens_look_numeric(header_tokens) + and _tokens_look_like_column_labels(header_tokens) + ): return _normalize_column_names(header_tokens) first_data_tokens = _first_data_tokens(lines, header_rows) if first_data_tokens is None: @@ -5174,12 +5186,49 @@ def _first_data_tokens( stripped = line.strip() if not stripped: continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + continue + tokens = _split_experimental_line(_strip_comment_prefix(stripped)) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return tokens return None +def _strip_comment_prefix(line: str) -> str: + return line.lstrip("#").strip() if line.lstrip().startswith("#") else line + + +def _is_comment_metadata_line(line: str) -> bool: + if not line.lstrip().startswith("#"): + return False + candidate = _strip_comment_prefix(line) + if not candidate: + return True + tokens = _split_experimental_line(candidate) + if len(tokens) >= 2 and _tokens_look_numeric(tokens): + return False + if ":" in candidate and not _tokens_look_like_column_labels(tokens): + return True + return not _tokens_look_like_column_labels(tokens) + + +def _tokens_look_like_column_labels(tokens: list[str]) -> bool: + if len(tokens) < 2: + return False + normalized = [ + re.sub(r"[^a-z0-9]+", "", token.lower()) for token in tokens + ] + if all(not token for token in normalized): + return False + if _tokens_look_numeric(tokens): + return False + keywords = ("q", "iq", "intensity", "error", "sigma", "uncert") + return any( + any(keyword in token for keyword in keywords) + for token in normalized + ) + + def _split_experimental_line(line: str) -> list[str]: stripped = line.strip() if not stripped: diff --git a/src/saxshell/saxs/ui/experimental_data_loader.py b/src/saxshell/saxs/ui/experimental_data_loader.py index 7477d60..8447ad1 100644 --- a/src/saxshell/saxs/ui/experimental_data_loader.py +++ b/src/saxshell/saxs/ui/experimental_data_loader.py @@ -5,12 +5,14 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QApplication, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QPlainTextEdit, + QSizePolicy, QSpinBox, QVBoxLayout, QWidget, @@ -75,7 +77,18 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle("Check Experimental Data File") - self.resize(900, 720) + screen = QApplication.primaryScreen() + if screen is None: + self.resize(900, 600) + else: + available = screen.availableGeometry() + target_width = min(900, int(available.width() * 0.85)) + target_height = min(600, int(available.height() * 0.85)) + self.resize(max(480, target_width), max(360, target_height)) + self.setMaximumSize( + max(520, int(available.width() * 0.95)), + max(420, int(available.height() * 0.95)), + ) root = QVBoxLayout(self) intro_label = QLabel( @@ -91,6 +104,12 @@ def _build_ui(self) -> None: self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) + self.file_label.setWordWrap(True) + self.file_label.setMinimumWidth(0) + self.file_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) form.addRow("File", self.file_label) self.header_rows_spin = QSpinBox() @@ -99,25 +118,35 @@ def _build_ui(self) -> None: form.addRow("Header rows", self.header_rows_spin) self.q_column_combo = QComboBox() + self._configure_column_combo(self.q_column_combo) form.addRow("q column", self.q_column_combo) self.intensity_column_combo = QComboBox() + self._configure_column_combo(self.intensity_column_combo) form.addRow("Intensity column", self.intensity_column_combo) self.error_column_combo = QComboBox() + self._configure_column_combo(self.error_column_combo) form.addRow("Error column", self.error_column_combo) root.addLayout(form) self.preview_box = QPlainTextEdit() self.preview_box.setReadOnly(True) - self.preview_box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.preview_box.setMinimumHeight(420) - root.addWidget(self.preview_box) + self.preview_box.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + ) + self.preview_box.setMinimumHeight(250) + root.addWidget(self.preview_box, stretch=1) self.status_label = QLabel( "Adjust the header length and selected columns, then click Load File." ) self.status_label.setWordWrap(True) + self.status_label.setMinimumWidth(0) + self.status_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) root.addWidget(self.status_label) button_box = QDialogButtonBox( @@ -132,6 +161,16 @@ def _build_ui(self) -> None: button_box.rejected.connect(self.reject) root.addWidget(button_box) + def _configure_column_combo(self, combo: QComboBox) -> None: + combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + combo.setMinimumContentsLength(1) + combo.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Fixed, + ) + def _read_preview_lines(self, max_lines: int = 80) -> list[str]: lines: list[str] = [] with self.file_path.open( diff --git a/tests/test_saxs_ui.py b/tests/test_saxs_ui.py index c19c535..5af238f 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -124,9 +124,12 @@ SAXSProjectManager, build_prior_histogram_export_payload, build_project_paths, + guess_experimental_header_rows, + infer_experimental_columns, load_experimental_data_file, plot_md_prior_histogram, project_artifact_paths, + read_experimental_column_names, ) from saxshell.saxs.solution_scattering_estimator import ( SolutionScatteringEstimatorSettings, @@ -13294,6 +13297,85 @@ def test_experimental_data_header_dialog_allows_manual_column_selection( assert np.allclose(dialog.accepted_summary.errors, [0.1, 0.2]) +def test_experimental_data_header_dialog_geometry_is_screen_bounded( + qapp, tmp_path +): + del qapp + data_path = tmp_path / "exp_long_lines.txt" + long_path_segment = "OneDrive - UCB-O365" * 20 + data_path.write_text( + ( + f"{long_path_segment}\n" + f"{'q intensity error ' * 80}\n" + f"{'0.05 10.0 0.1 ' * 80}\n" + ), + encoding="utf-8", + ) + + dialog = ExperimentalDataHeaderDialog(data_path) + + assert dialog.file_label.wordWrap() + assert dialog.file_label.minimumWidth() == 0 + assert ( + dialog.preview_box.lineWrapMode() + == dialog.preview_box.LineWrapMode.WidgetWidth + ) + assert dialog.preview_box.minimumHeight() == 250 + assert dialog.status_label.wordWrap() + assert dialog.status_label.minimumWidth() == 0 + assert ( + dialog.q_column_combo.sizeAdjustPolicy() + == dialog.q_column_combo.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + max_size = dialog.maximumSize() + assert max_size.width() <= available.width() + assert max_size.height() <= available.height() + assert dialog.minimumWidth() <= available.width() + assert dialog.minimumHeight() <= available.height() + + +def test_experimental_data_metadata_comments_are_not_used_as_columns( + tmp_path, +): + data_path = tmp_path / "exp_with_metadata_comments.txt" + data_path.write_text( + "# Background offset factor: 1.0\n" + "# q_(Å⁻¹) I(q)\n" + "0.01 100.0\n" + "0.02 95.0\n" + "0.03 90.0\n", + encoding="utf-8", + ) + + header_rows = guess_experimental_header_rows(data_path) + assert header_rows == 2 + + column_names = read_experimental_column_names( + data_path, + skiprows=header_rows, + ) + assert column_names == ["q_(Å⁻¹)", "I(q)"] + assert "#" not in column_names + assert "Background" not in column_names + + inferred_q, inferred_i, inferred_e = infer_experimental_columns( + column_names + ) + assert inferred_q == 0 + assert inferred_i == 1 + assert inferred_e is None + + summary = load_experimental_data_file(data_path) + assert summary.header_rows == 2 + assert summary.column_names == ["q_(Å⁻¹)", "I(q)"] + assert np.allclose(summary.q_values, [0.01, 0.02, 0.03]) + assert np.allclose(summary.intensities, [100.0, 95.0, 90.0]) + + def test_project_setup_preview_updates_with_experimental_q_range( qapp, tmp_path ): From 7ab5f84fd9550cc729f2da6fd192e496965490c6 Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Thu, 14 May 2026 16:32:02 -0600 Subject: [PATCH 5/7] Handle inline metadata+header comment lines in experimental parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a commented line includes metadata and a trailing inline header fragment, extract the header-like segment instead of tokenizing metadata words as column labels. Add regression coverage for '# ... factor: 1.0 # q_(Å⁻¹) I(q)' style inputs. --- src/saxshell/saxs/project_manager/project.py | 67 ++++++++++- .../saxs/ui/experimental_data_loader.py | 47 +++++++- tests/test_saxs_ui.py | 104 ++++++++++++++++++ 3 files changed, 210 insertions(+), 8 deletions(-) diff --git a/src/saxshell/saxs/project_manager/project.py b/src/saxshell/saxs/project_manager/project.py index 487679d..6f97b19 100644 --- a/src/saxshell/saxs/project_manager/project.py +++ b/src/saxshell/saxs/project_manager/project.py @@ -5140,7 +5140,14 @@ def _guess_experimental_header_rows(file_path: Path) -> int: if not stripped: header_rows += 1 continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + header_rows += 1 + continue + candidate_line = _strip_comment_prefix(stripped) + if not candidate_line: + header_rows += 1 + continue + tokens = _split_experimental_line(candidate_line) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return header_rows header_rows += 1 @@ -5155,10 +5162,15 @@ def _read_experimental_column_names( encoding="utf-8", errors="replace" ).splitlines() if header_rows > 0 and header_rows <= len(lines): + header_line = lines[header_rows - 1].strip() header_tokens = _split_experimental_line( - lines[header_rows - 1].lstrip("#").strip() + _strip_comment_prefix(header_line) ) - if header_tokens and not _tokens_look_numeric(header_tokens): + if ( + header_tokens + and not _tokens_look_numeric(header_tokens) + and _tokens_look_like_column_labels(header_tokens) + ): return _normalize_column_names(header_tokens) first_data_tokens = _first_data_tokens(lines, header_rows) if first_data_tokens is None: @@ -5174,12 +5186,59 @@ def _first_data_tokens( stripped = line.strip() if not stripped: continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + continue + tokens = _split_experimental_line(_strip_comment_prefix(stripped)) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return tokens return None +def _strip_comment_prefix(line: str) -> str: + if not line.lstrip().startswith("#"): + return line + raw = line.lstrip("#").strip() + if "#" not in raw: + return raw + segments = [segment.strip() for segment in raw.split("#") if segment.strip()] + for segment in reversed(segments): + tokens = _split_experimental_line(segment) + if _tokens_look_like_column_labels(tokens): + return segment + return segments[-1] if segments else "" + + +def _is_comment_metadata_line(line: str) -> bool: + if not line.lstrip().startswith("#"): + return False + candidate = _strip_comment_prefix(line) + if not candidate: + return True + tokens = _split_experimental_line(candidate) + if len(tokens) >= 2 and _tokens_look_numeric(tokens): + return False + if ":" in candidate and not _tokens_look_like_column_labels(tokens): + return True + return not _tokens_look_like_column_labels(tokens) + + +def _tokens_look_like_column_labels(tokens: list[str]) -> bool: + if len(tokens) < 2: + return False + normalized = [ + re.sub(r"[^a-z0-9]+", "", token.lower()) for token in tokens + ] + if all(not token for token in normalized): + return False + if _tokens_look_numeric(tokens): + return False + keywords = ("q", "iq", "intensity", "error", "sigma", "uncert") + return any( + any(keyword in token for keyword in keywords) + for token in normalized + ) + + def _split_experimental_line(line: str) -> list[str]: stripped = line.strip() if not stripped: diff --git a/src/saxshell/saxs/ui/experimental_data_loader.py b/src/saxshell/saxs/ui/experimental_data_loader.py index 7477d60..8447ad1 100644 --- a/src/saxshell/saxs/ui/experimental_data_loader.py +++ b/src/saxshell/saxs/ui/experimental_data_loader.py @@ -5,12 +5,14 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QApplication, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QPlainTextEdit, + QSizePolicy, QSpinBox, QVBoxLayout, QWidget, @@ -75,7 +77,18 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle("Check Experimental Data File") - self.resize(900, 720) + screen = QApplication.primaryScreen() + if screen is None: + self.resize(900, 600) + else: + available = screen.availableGeometry() + target_width = min(900, int(available.width() * 0.85)) + target_height = min(600, int(available.height() * 0.85)) + self.resize(max(480, target_width), max(360, target_height)) + self.setMaximumSize( + max(520, int(available.width() * 0.95)), + max(420, int(available.height() * 0.95)), + ) root = QVBoxLayout(self) intro_label = QLabel( @@ -91,6 +104,12 @@ def _build_ui(self) -> None: self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) + self.file_label.setWordWrap(True) + self.file_label.setMinimumWidth(0) + self.file_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) form.addRow("File", self.file_label) self.header_rows_spin = QSpinBox() @@ -99,25 +118,35 @@ def _build_ui(self) -> None: form.addRow("Header rows", self.header_rows_spin) self.q_column_combo = QComboBox() + self._configure_column_combo(self.q_column_combo) form.addRow("q column", self.q_column_combo) self.intensity_column_combo = QComboBox() + self._configure_column_combo(self.intensity_column_combo) form.addRow("Intensity column", self.intensity_column_combo) self.error_column_combo = QComboBox() + self._configure_column_combo(self.error_column_combo) form.addRow("Error column", self.error_column_combo) root.addLayout(form) self.preview_box = QPlainTextEdit() self.preview_box.setReadOnly(True) - self.preview_box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.preview_box.setMinimumHeight(420) - root.addWidget(self.preview_box) + self.preview_box.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + ) + self.preview_box.setMinimumHeight(250) + root.addWidget(self.preview_box, stretch=1) self.status_label = QLabel( "Adjust the header length and selected columns, then click Load File." ) self.status_label.setWordWrap(True) + self.status_label.setMinimumWidth(0) + self.status_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) root.addWidget(self.status_label) button_box = QDialogButtonBox( @@ -132,6 +161,16 @@ def _build_ui(self) -> None: button_box.rejected.connect(self.reject) root.addWidget(button_box) + def _configure_column_combo(self, combo: QComboBox) -> None: + combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + combo.setMinimumContentsLength(1) + combo.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Fixed, + ) + def _read_preview_lines(self, max_lines: int = 80) -> list[str]: lines: list[str] = [] with self.file_path.open( diff --git a/tests/test_saxs_ui.py b/tests/test_saxs_ui.py index c19c535..ab7863f 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -124,9 +124,12 @@ SAXSProjectManager, build_prior_histogram_export_payload, build_project_paths, + guess_experimental_header_rows, + infer_experimental_columns, load_experimental_data_file, plot_md_prior_histogram, project_artifact_paths, + read_experimental_column_names, ) from saxshell.saxs.solution_scattering_estimator import ( SolutionScatteringEstimatorSettings, @@ -13294,6 +13297,107 @@ def test_experimental_data_header_dialog_allows_manual_column_selection( assert np.allclose(dialog.accepted_summary.errors, [0.1, 0.2]) +def test_experimental_data_header_dialog_geometry_is_screen_bounded( + qapp, tmp_path +): + del qapp + data_path = tmp_path / "exp_long_lines.txt" + long_path_segment = "OneDrive - UCB-O365" * 20 + data_path.write_text( + ( + f"{long_path_segment}\n" + f"{'q intensity error ' * 80}\n" + f"{'0.05 10.0 0.1 ' * 80}\n" + ), + encoding="utf-8", + ) + + dialog = ExperimentalDataHeaderDialog(data_path) + + assert dialog.file_label.wordWrap() + assert dialog.file_label.minimumWidth() == 0 + assert ( + dialog.preview_box.lineWrapMode() + == dialog.preview_box.LineWrapMode.WidgetWidth + ) + assert dialog.preview_box.minimumHeight() == 250 + assert dialog.status_label.wordWrap() + assert dialog.status_label.minimumWidth() == 0 + assert ( + dialog.q_column_combo.sizeAdjustPolicy() + == dialog.q_column_combo.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + max_size = dialog.maximumSize() + assert max_size.width() <= available.width() + assert max_size.height() <= available.height() + assert dialog.minimumWidth() <= available.width() + assert dialog.minimumHeight() <= available.height() + + +def test_experimental_data_metadata_comments_are_not_used_as_columns( + tmp_path, +): + data_path = tmp_path / "exp_with_metadata_comments.txt" + data_path.write_text( + "# Background offset factor: 1.0\n" + "# q_(Å⁻¹) I(q)\n" + "0.01 100.0\n" + "0.02 95.0\n" + "0.03 90.0\n", + encoding="utf-8", + ) + + header_rows = guess_experimental_header_rows(data_path) + assert header_rows == 2 + + column_names = read_experimental_column_names( + data_path, + skiprows=header_rows, + ) + assert column_names == ["q_(Å⁻¹)", "I(q)"] + assert "#" not in column_names + assert "Background" not in column_names + + inferred_q, inferred_i, inferred_e = infer_experimental_columns( + column_names + ) + assert inferred_q == 0 + assert inferred_i == 1 + assert inferred_e is None + + summary = load_experimental_data_file(data_path) + assert summary.header_rows == 2 + assert summary.column_names == ["q_(Å⁻¹)", "I(q)"] + assert np.allclose(summary.q_values, [0.01, 0.02, 0.03]) + assert np.allclose(summary.intensities, [100.0, 95.0, 90.0]) + + +def test_experimental_data_ignores_metadata_prefix_before_inline_header( + tmp_path, +): + data_path = tmp_path / "exp_inline_header_comment.txt" + data_path.write_text( + "# Background offset factor: 1.0 # q_(Å⁻¹) I(q)\n" + "0.01 100.0\n" + "0.02 95.0\n", + encoding="utf-8", + ) + + header_rows = guess_experimental_header_rows(data_path) + assert header_rows == 1 + column_names = read_experimental_column_names(data_path, skiprows=1) + assert column_names == ["q_(Å⁻¹)", "I(q)"] + + summary = load_experimental_data_file(data_path) + assert summary.column_names == ["q_(Å⁻¹)", "I(q)"] + assert np.allclose(summary.q_values, [0.01, 0.02]) + assert np.allclose(summary.intensities, [100.0, 95.0]) + + def test_project_setup_preview_updates_with_experimental_q_range( qapp, tmp_path ): From d0209a83932d29d53ea158c78ddd7b7c4424c443 Mon Sep 17 00:00:00 2001 From: KWWyatt <117381914+KWWyatt@users.noreply.github.com> Date: Thu, 14 May 2026 16:49:10 -0600 Subject: [PATCH 6/7] Fix experimental data decode failures on non-UTF8 bytes Load numeric data through a UTF-8 text stream with errors='replace' instead of letting numpy decode the file path with platform-default codecs. This prevents charmap decode crashes from metadata bytes while preserving numeric row parsing. Add regression coverage for comments containing invalid UTF-8 bytes. --- src/saxshell/saxs/project_manager/project.py | 86 ++++++++++-- .../saxs/ui/experimental_data_loader.py | 47 ++++++- tests/test_saxs_ui.py | 123 ++++++++++++++++++ 3 files changed, 244 insertions(+), 12 deletions(-) diff --git a/src/saxshell/saxs/project_manager/project.py b/src/saxshell/saxs/project_manager/project.py index 487679d..63928f6 100644 --- a/src/saxshell/saxs/project_manager/project.py +++ b/src/saxshell/saxs/project_manager/project.py @@ -4964,9 +4964,8 @@ def load_experimental_data_file( effective_skiprows = max(skiprows, 0) parse_error: Exception | None = None try: - data = np.loadtxt( + data = _load_experimental_numeric_data( file_path, - comments="#", skiprows=effective_skiprows, ) column_names = _read_experimental_column_names( @@ -4987,9 +4986,8 @@ def load_experimental_data_file( effective_skiprows, ) try: - data = np.loadtxt( + data = _load_experimental_numeric_data( file_path, - comments="#", skiprows=effective_skiprows, ) except Exception as header_exc: @@ -5140,13 +5138,33 @@ def _guess_experimental_header_rows(file_path: Path) -> int: if not stripped: header_rows += 1 continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + header_rows += 1 + continue + candidate_line = _strip_comment_prefix(stripped) + if not candidate_line: + header_rows += 1 + continue + tokens = _split_experimental_line(candidate_line) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return header_rows header_rows += 1 return 0 +def _load_experimental_numeric_data( + file_path: Path, + *, + skiprows: int, +) -> np.ndarray: + with file_path.open("r", encoding="utf-8", errors="replace") as handle: + return np.loadtxt( + handle, + comments="#", + skiprows=max(skiprows, 0), + ) + + def _read_experimental_column_names( file_path: Path, header_rows: int, @@ -5155,10 +5173,15 @@ def _read_experimental_column_names( encoding="utf-8", errors="replace" ).splitlines() if header_rows > 0 and header_rows <= len(lines): + header_line = lines[header_rows - 1].strip() header_tokens = _split_experimental_line( - lines[header_rows - 1].lstrip("#").strip() + _strip_comment_prefix(header_line) ) - if header_tokens and not _tokens_look_numeric(header_tokens): + if ( + header_tokens + and not _tokens_look_numeric(header_tokens) + and _tokens_look_like_column_labels(header_tokens) + ): return _normalize_column_names(header_tokens) first_data_tokens = _first_data_tokens(lines, header_rows) if first_data_tokens is None: @@ -5174,12 +5197,59 @@ def _first_data_tokens( stripped = line.strip() if not stripped: continue - tokens = _split_experimental_line(stripped) + if _is_comment_metadata_line(stripped): + continue + tokens = _split_experimental_line(_strip_comment_prefix(stripped)) if len(tokens) >= 2 and _tokens_look_numeric(tokens): return tokens return None +def _strip_comment_prefix(line: str) -> str: + if not line.lstrip().startswith("#"): + return line + raw = line.lstrip("#").strip() + if "#" not in raw: + return raw + segments = [segment.strip() for segment in raw.split("#") if segment.strip()] + for segment in reversed(segments): + tokens = _split_experimental_line(segment) + if _tokens_look_like_column_labels(tokens): + return segment + return segments[-1] if segments else "" + + +def _is_comment_metadata_line(line: str) -> bool: + if not line.lstrip().startswith("#"): + return False + candidate = _strip_comment_prefix(line) + if not candidate: + return True + tokens = _split_experimental_line(candidate) + if len(tokens) >= 2 and _tokens_look_numeric(tokens): + return False + if ":" in candidate and not _tokens_look_like_column_labels(tokens): + return True + return not _tokens_look_like_column_labels(tokens) + + +def _tokens_look_like_column_labels(tokens: list[str]) -> bool: + if len(tokens) < 2: + return False + normalized = [ + re.sub(r"[^a-z0-9]+", "", token.lower()) for token in tokens + ] + if all(not token for token in normalized): + return False + if _tokens_look_numeric(tokens): + return False + keywords = ("q", "iq", "intensity", "error", "sigma", "uncert") + return any( + any(keyword in token for keyword in keywords) + for token in normalized + ) + + def _split_experimental_line(line: str) -> list[str]: stripped = line.strip() if not stripped: diff --git a/src/saxshell/saxs/ui/experimental_data_loader.py b/src/saxshell/saxs/ui/experimental_data_loader.py index 7477d60..8447ad1 100644 --- a/src/saxshell/saxs/ui/experimental_data_loader.py +++ b/src/saxshell/saxs/ui/experimental_data_loader.py @@ -5,12 +5,14 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QApplication, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QLabel, QPlainTextEdit, + QSizePolicy, QSpinBox, QVBoxLayout, QWidget, @@ -75,7 +77,18 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle("Check Experimental Data File") - self.resize(900, 720) + screen = QApplication.primaryScreen() + if screen is None: + self.resize(900, 600) + else: + available = screen.availableGeometry() + target_width = min(900, int(available.width() * 0.85)) + target_height = min(600, int(available.height() * 0.85)) + self.resize(max(480, target_width), max(360, target_height)) + self.setMaximumSize( + max(520, int(available.width() * 0.95)), + max(420, int(available.height() * 0.95)), + ) root = QVBoxLayout(self) intro_label = QLabel( @@ -91,6 +104,12 @@ def _build_ui(self) -> None: self.file_label.setTextInteractionFlags( Qt.TextInteractionFlag.TextSelectableByMouse ) + self.file_label.setWordWrap(True) + self.file_label.setMinimumWidth(0) + self.file_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) form.addRow("File", self.file_label) self.header_rows_spin = QSpinBox() @@ -99,25 +118,35 @@ def _build_ui(self) -> None: form.addRow("Header rows", self.header_rows_spin) self.q_column_combo = QComboBox() + self._configure_column_combo(self.q_column_combo) form.addRow("q column", self.q_column_combo) self.intensity_column_combo = QComboBox() + self._configure_column_combo(self.intensity_column_combo) form.addRow("Intensity column", self.intensity_column_combo) self.error_column_combo = QComboBox() + self._configure_column_combo(self.error_column_combo) form.addRow("Error column", self.error_column_combo) root.addLayout(form) self.preview_box = QPlainTextEdit() self.preview_box.setReadOnly(True) - self.preview_box.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) - self.preview_box.setMinimumHeight(420) - root.addWidget(self.preview_box) + self.preview_box.setLineWrapMode( + QPlainTextEdit.LineWrapMode.WidgetWidth + ) + self.preview_box.setMinimumHeight(250) + root.addWidget(self.preview_box, stretch=1) self.status_label = QLabel( "Adjust the header length and selected columns, then click Load File." ) self.status_label.setWordWrap(True) + self.status_label.setMinimumWidth(0) + self.status_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) root.addWidget(self.status_label) button_box = QDialogButtonBox( @@ -132,6 +161,16 @@ def _build_ui(self) -> None: button_box.rejected.connect(self.reject) root.addWidget(button_box) + def _configure_column_combo(self, combo: QComboBox) -> None: + combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + combo.setMinimumContentsLength(1) + combo.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Fixed, + ) + def _read_preview_lines(self, max_lines: int = 80) -> list[str]: lines: list[str] = [] with self.file_path.open( diff --git a/tests/test_saxs_ui.py b/tests/test_saxs_ui.py index c19c535..7a3c5ea 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -124,9 +124,12 @@ SAXSProjectManager, build_prior_histogram_export_payload, build_project_paths, + guess_experimental_header_rows, + infer_experimental_columns, load_experimental_data_file, plot_md_prior_histogram, project_artifact_paths, + read_experimental_column_names, ) from saxshell.saxs.solution_scattering_estimator import ( SolutionScatteringEstimatorSettings, @@ -13294,6 +13297,126 @@ def test_experimental_data_header_dialog_allows_manual_column_selection( assert np.allclose(dialog.accepted_summary.errors, [0.1, 0.2]) +def test_experimental_data_header_dialog_geometry_is_screen_bounded( + qapp, tmp_path +): + del qapp + data_path = tmp_path / "exp_long_lines.txt" + long_path_segment = "OneDrive - UCB-O365" * 20 + data_path.write_text( + ( + f"{long_path_segment}\n" + f"{'q intensity error ' * 80}\n" + f"{'0.05 10.0 0.1 ' * 80}\n" + ), + encoding="utf-8", + ) + + dialog = ExperimentalDataHeaderDialog(data_path) + + assert dialog.file_label.wordWrap() + assert dialog.file_label.minimumWidth() == 0 + assert ( + dialog.preview_box.lineWrapMode() + == dialog.preview_box.LineWrapMode.WidgetWidth + ) + assert dialog.preview_box.minimumHeight() == 250 + assert dialog.status_label.wordWrap() + assert dialog.status_label.minimumWidth() == 0 + assert ( + dialog.q_column_combo.sizeAdjustPolicy() + == dialog.q_column_combo.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + + screen = QApplication.primaryScreen() + if screen is not None: + available = screen.availableGeometry() + max_size = dialog.maximumSize() + assert max_size.width() <= available.width() + assert max_size.height() <= available.height() + assert dialog.minimumWidth() <= available.width() + assert dialog.minimumHeight() <= available.height() + + +def test_experimental_data_metadata_comments_are_not_used_as_columns( + tmp_path, +): + data_path = tmp_path / "exp_with_metadata_comments.txt" + data_path.write_text( + "# Background offset factor: 1.0\n" + "# q_(Å⁻¹) I(q)\n" + "0.01 100.0\n" + "0.02 95.0\n" + "0.03 90.0\n", + encoding="utf-8", + ) + + header_rows = guess_experimental_header_rows(data_path) + assert header_rows == 2 + + column_names = read_experimental_column_names( + data_path, + skiprows=header_rows, + ) + assert column_names == ["q_(Å⁻¹)", "I(q)"] + assert "#" not in column_names + assert "Background" not in column_names + + inferred_q, inferred_i, inferred_e = infer_experimental_columns( + column_names + ) + assert inferred_q == 0 + assert inferred_i == 1 + assert inferred_e is None + + summary = load_experimental_data_file(data_path) + assert summary.header_rows == 2 + assert summary.column_names == ["q_(Å⁻¹)", "I(q)"] + assert np.allclose(summary.q_values, [0.01, 0.02, 0.03]) + assert np.allclose(summary.intensities, [100.0, 95.0, 90.0]) + + +def test_experimental_data_ignores_metadata_prefix_before_inline_header( + tmp_path, +): + data_path = tmp_path / "exp_inline_header_comment.txt" + data_path.write_text( + "# Background offset factor: 1.0 # q_(Å⁻¹) I(q)\n" + "0.01 100.0\n" + "0.02 95.0\n", + encoding="utf-8", + ) + + header_rows = guess_experimental_header_rows(data_path) + assert header_rows == 1 + column_names = read_experimental_column_names(data_path, skiprows=1) + assert column_names == ["q_(Å⁻¹)", "I(q)"] + + summary = load_experimental_data_file(data_path) + assert summary.column_names == ["q_(Å⁻¹)", "I(q)"] + assert np.allclose(summary.q_values, [0.01, 0.02]) + assert np.allclose(summary.intensities, [100.0, 95.0]) + + +def test_experimental_data_load_tolerates_non_utf8_bytes_in_comments( + tmp_path, +): + data_path = tmp_path / "exp_non_utf8_comment.txt" + data_path.write_bytes( + b"# instrument metadata \x81\n" + b"# q I\n" + b"0.01 100.0\n" + b"0.02 95.0\n" + ) + + summary = load_experimental_data_file(data_path) + + assert summary.header_rows == 2 + assert summary.column_names == ["q", "I"] + assert np.allclose(summary.q_values, [0.01, 0.02]) + assert np.allclose(summary.intensities, [100.0, 95.0]) + + def test_project_setup_preview_updates_with_experimental_q_range( qapp, tmp_path ): From 8fa8cd8fa86fe76ce4cca412890bea5e8970b3b1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 18:27:41 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit hooks --- src/saxshell/saxs/project_manager/project.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/saxshell/saxs/project_manager/project.py b/src/saxshell/saxs/project_manager/project.py index 90dfca5..3a7df48 100644 --- a/src/saxshell/saxs/project_manager/project.py +++ b/src/saxshell/saxs/project_manager/project.py @@ -5226,17 +5226,14 @@ def _is_comment_metadata_line(line: str) -> bool: def _tokens_look_like_column_labels(tokens: list[str]) -> bool: if len(tokens) < 2: return False - normalized = [ - re.sub(r"[^a-z0-9]+", "", token.lower()) for token in tokens - ] + normalized = [re.sub(r"[^a-z0-9]+", "", token.lower()) for token in tokens] if all(not token for token in normalized): return False if _tokens_look_numeric(tokens): return False keywords = ("q", "iq", "intensity", "error", "sigma", "uncert") return any( - any(keyword in token for keyword in keywords) - for token in normalized + any(keyword in token for keyword in keywords) for token in normalized )