diff --git a/src/saxshell/saxs/project_manager/project.py b/src/saxshell/saxs/project_manager/project.py index 487679d..3a7df48 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,46 @@ 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 38f0955..c21acb7 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, @@ -85,7 +87,18 @@ def error_column(self) -> int | None: def _build_ui(self) -> None: self.setWindowTitle(self._dialog_title) - 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( @@ -105,6 +118,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() @@ -113,28 +132,38 @@ 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(self._independent_column_label, self.q_column_combo) self.intensity_column_combo = QComboBox() + self._configure_column_combo(self.intensity_column_combo) form.addRow( self._dependent_column_label, self.intensity_column_combo, ) self.error_column_combo = QComboBox() + self._configure_column_combo(self.error_column_combo) form.addRow(self._error_column_label, 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( @@ -149,6 +178,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 f6da8b6..e3fe51e 100644 --- a/tests/test_saxs_ui.py +++ b/tests/test_saxs_ui.py @@ -33,6 +33,7 @@ QInputDialog, QLabel, QMessageBox, + QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, @@ -125,9 +126,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, @@ -14108,6 +14112,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 ):