Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c2fa001
Fix project-load progress updates to run on UI thread
KWWyatt May 13, 2026
cba0473
Merge pull request #1 from KWWyatt/codex/fix-project-loader-crash-on-…
KWWyatt May 13, 2026
8f808b8
Merge pull request #2 from KWWyatt/fix/project-load-threaded-progress…
KWWyatt May 14, 2026
f5a8de6
Merge branch 'main' of https://github.com/KWWyatt/SAXSShell
KWWyatt May 14, 2026
90df724
Constrain experimental data header dialog geometry
KWWyatt May 14, 2026
f5431e4
Merge pull request #3 from KWWyatt/codex/fix-crash-in-experimentaldat…
KWWyatt May 14, 2026
567bb48
Bound experimental data header dialog to screen size
KWWyatt May 14, 2026
6a182e0
Merge branch 'fix-experimental-data-dialog-crash' into codex/fix-cras…
KWWyatt May 14, 2026
f7fba4d
Merge pull request #4 from KWWyatt/codex/fix-crash-in-experimentaldat…
KWWyatt May 14, 2026
2ab9de9
Fix experimental header parsing and dialog width inflation
KWWyatt May 14, 2026
cd700a4
Merge branch 'fix-experimental-data-dialog-crash' into codex/fix-cras…
KWWyatt May 14, 2026
81e4dca
Merge pull request #5 from KWWyatt/codex/fix-crash-in-experimentaldat…
KWWyatt May 14, 2026
7ab5f84
Handle inline metadata+header comment lines in experimental parser
KWWyatt May 14, 2026
5f5f8ea
Merge branch 'fix-experimental-data-dialog-crash' into codex/fix-cras…
KWWyatt May 14, 2026
6da0e0d
Merge pull request #6 from KWWyatt/codex/fix-crash-in-experimentaldat…
KWWyatt May 14, 2026
d0209a8
Fix experimental data decode failures on non-UTF8 bytes
KWWyatt May 14, 2026
8a4456f
Merge branch 'fix-experimental-data-dialog-crash' into codex/fix-cras…
KWWyatt May 14, 2026
bdef873
Merge pull request #7 from KWWyatt/codex/fix-crash-in-experimentaldat…
KWWyatt May 14, 2026
6ec736c
Merge pull request #8 from KWWyatt/fix-experimental-data-dialog-crash
KWWyatt May 14, 2026
5884b4f
Merge remote-tracking branch 'upstream/main' into merge-upstream-main
KWWyatt May 21, 2026
6ca50f4
Merge pull request #10 from KWWyatt/merge-upstream-main
KWWyatt May 26, 2026
8fa8cd8
[pre-commit.ci] auto fixes from pre-commit hooks
pre-commit-ci[bot] May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 deletions src/saxshell/saxs/project_manager/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand Down
47 changes: 43 additions & 4 deletions src/saxshell/saxs/ui/experimental_data_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QLabel,
QPlainTextEdit,
QSizePolicy,
QSpinBox,
QVBoxLayout,
QWidget,
Expand Down Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -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(
Expand All @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions tests/test_saxs_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
QInputDialog,
QLabel,
QMessageBox,
QPlainTextEdit,
QPushButton,
QScrollArea,
QSizePolicy,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
):
Expand Down
Loading