CI for csaxs_bec / test (push) Successful in 3m4s
Angle files now accept -2 to 362 degrees (the fsamroy rotation stage's physical overtravel range) instead of the nominal 0-360 sweep. This required widening both the parser's bounds (angle_file.py) and _tomo_scan_at_angle()'s own gate (a new _min_scan_angle(), alongside the existing _max_scan_angle()) -- without the latter, an angle just outside [0, 360] would have parsed fine but then been silently skipped during acquisition with no error, which is worse than rejecting it upfront. Also fixes the type-4 GUI section's "Content:" label clipping its second wrapped line: QFormLayout sizes that row from the label's sizeHint() at construction (holding the short "---" placeholder) and doesn't reliably recompute it when setText() later swaps in longer, word-wrapped content. _set_angle_file_summary() now recomputes the label's minimum height from heightForWidth() after every update. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPZ9gzN2rt3wgiMnTKyrug
261 lines
8.4 KiB
Python
261 lines
8.4 KiB
Python
"""Tests for the tomo_type-4 angle file parser (OMNY_shared/angle_file.py)."""
|
|
|
|
import pytest
|
|
|
|
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
|
|
AngleEntry,
|
|
AngleFileError,
|
|
describe_opposite_pairs,
|
|
describe_summary,
|
|
read_angle_file,
|
|
summarize_angles,
|
|
)
|
|
|
|
|
|
def _e(angle, subtomo=1, step=None, exp_time=None, frames_per_trigger=None):
|
|
"""Shorthand for building the expected AngleEntry list in assertions."""
|
|
return AngleEntry(
|
|
angle=angle, subtomo=subtomo, step=step, exp_time=exp_time,
|
|
frames_per_trigger=frames_per_trigger,
|
|
)
|
|
|
|
|
|
def _write(tmp_path, text, name="angles.txt"):
|
|
path = tmp_path / name
|
|
path.write_text(text)
|
|
return str(path)
|
|
|
|
|
|
def test_one_column_defaults_to_subtomo_1(tmp_path):
|
|
path = _write(tmp_path, "0\n45.5\n360\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(0.0), _e(45.5), _e(360.0)]
|
|
assert result.is_complete is False
|
|
|
|
|
|
def test_two_columns_whitespace_and_comma_separators(tmp_path):
|
|
path = _write(tmp_path, "0 1\n10\t1\n190.5, 2\n270,3\n")
|
|
assert read_angle_file(path).angles == [_e(0.0), _e(10.0), _e(190.5, 2), _e(270.0, 3)]
|
|
|
|
|
|
def test_comments_and_blank_lines_ignored_and_order_kept(tmp_path):
|
|
path = _write(tmp_path, "# header\n\n90 2 # trailing comment\n \n30 1\n")
|
|
assert read_angle_file(path).angles == [_e(90.0, 2), _e(30.0, 1)]
|
|
|
|
|
|
def test_tilde_expansion(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
_write(tmp_path, "12\n")
|
|
assert read_angle_file("~/angles.txt").angles == [_e(12.0)]
|
|
|
|
|
|
def test_angle_range_matches_rotation_stage_overtravel(tmp_path):
|
|
"""-2 and 362 (the stage's physical overtravel limits) must parse, not
|
|
just the nominal 0-360 sweep."""
|
|
path = _write(tmp_path, "-2\n-0.1\n0\n360.01\n362\n")
|
|
assert read_angle_file(path).angles == [
|
|
_e(-2.0),
|
|
_e(-0.1),
|
|
_e(0.0),
|
|
_e(360.01),
|
|
_e(362.0),
|
|
]
|
|
|
|
|
|
def test_end_marks_complete(tmp_path):
|
|
path = _write(tmp_path, "0\n10\nEND\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(0.0), _e(10.0)]
|
|
assert result.is_complete is True
|
|
|
|
|
|
def test_missing_end_is_incomplete(tmp_path):
|
|
path = _write(tmp_path, "0\n10\n")
|
|
assert read_angle_file(path).is_complete is False
|
|
|
|
|
|
@pytest.mark.parametrize("marker", ["END", "end", "EnD"])
|
|
def test_end_case_insensitive(tmp_path, marker):
|
|
path = _write(tmp_path, f"0\n{marker}\n")
|
|
assert read_angle_file(path).is_complete is True
|
|
|
|
|
|
def test_commented_end_is_not_sentinel(tmp_path):
|
|
path = _write(tmp_path, "0\n# END\n10\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(0.0), _e(10.0)]
|
|
assert result.is_complete is False
|
|
|
|
|
|
def test_end_with_trailing_comment(tmp_path):
|
|
path = _write(tmp_path, "0\nEND # done\n")
|
|
assert read_angle_file(path).is_complete is True
|
|
|
|
|
|
def test_content_after_end_raises(tmp_path):
|
|
path = _write(tmp_path, "0\nEND\n10\n")
|
|
with pytest.raises(AngleFileError, match="line 3.*line 2"):
|
|
read_angle_file(path)
|
|
|
|
|
|
def test_blank_and_comment_after_end_allowed(tmp_path):
|
|
path = _write(tmp_path, "0\nEND\n\n# trailing note\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(0.0)]
|
|
assert result.is_complete is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text, match",
|
|
[
|
|
("-2.1\n", "outside"),
|
|
("362.01\n", "outside"),
|
|
("abc\n", "invalid angle"),
|
|
("10 x\n", "invalid sub-tomogram"),
|
|
("10 0\n", ">= 1"),
|
|
("10 1 5\n", "expected"),
|
|
("# only comments\n\n", "no angles"),
|
|
],
|
|
)
|
|
def test_invalid_content_raises_with_reason(tmp_path, text, match):
|
|
path = _write(tmp_path, text)
|
|
with pytest.raises(AngleFileError, match=match):
|
|
read_angle_file(path)
|
|
|
|
|
|
def test_error_reports_line_number(tmp_path):
|
|
path = _write(tmp_path, "# c\n10\nbad\n")
|
|
with pytest.raises(AngleFileError, match="line 3"):
|
|
read_angle_file(path)
|
|
|
|
|
|
def test_missing_file_and_empty_path_raise(tmp_path):
|
|
with pytest.raises(AngleFileError, match="Cannot read"):
|
|
read_angle_file(str(tmp_path / "nope.txt"))
|
|
with pytest.raises(AngleFileError, match="No angle file"):
|
|
read_angle_file("")
|
|
|
|
|
|
def test_angle_file_error_is_a_value_error():
|
|
assert issubclass(AngleFileError, ValueError)
|
|
|
|
|
|
def test_summary_counts():
|
|
summary = summarize_angles([_e(0.0), _e(45.0), _e(100.0, 3)])
|
|
assert summary["n"] == 3
|
|
assert summary["min"] == 0.0
|
|
assert summary["max"] == 100.0
|
|
assert summary["n_subtomos"] == 2
|
|
assert summary["n_overridden"] == 0
|
|
assert summary["opposite_pairs"] == []
|
|
assert describe_summary(summary) == "3 angles, 0.00-100.00 deg, 2 sub-tomograms"
|
|
assert describe_opposite_pairs(summary) is None
|
|
|
|
|
|
def test_summary_detects_180_opposite_and_exact_duplicates():
|
|
entries = [_e(10.0), _e(190.0), _e(50.0), _e(50.0, 2), _e(120.0)]
|
|
pairs = summarize_angles(entries)["opposite_pairs"]
|
|
assert [(a, b) for a, b, _, _ in pairs] == [(0, 1), (2, 3)]
|
|
|
|
|
|
def test_summary_detects_pair_across_the_mod_180_wrap():
|
|
# 179.99 and 0.0 are 0.01 apart mod 180; so are 359.99 and 0.0
|
|
pairs = summarize_angles([_e(0.0), _e(90.0), _e(359.99)])["opposite_pairs"]
|
|
assert [(a, b) for a, b, _, _ in pairs] == [(0, 2)]
|
|
|
|
|
|
def test_summary_no_false_positives_for_equally_spaced_360():
|
|
# 0, 7, 14, ... 353: 360/7 is not an integer, so no 180-degree pairs
|
|
entries = [_e(float(a)) for a in range(0, 360, 7)]
|
|
assert summarize_angles(entries)["opposite_pairs"] == []
|
|
|
|
|
|
def test_summary_flags_every_pair_for_equally_spaced_360_with_even_count():
|
|
entries = [_e(float(a)) for a in range(0, 360, 10)]
|
|
assert len(summarize_angles(entries)["opposite_pairs"]) == 18
|
|
|
|
|
|
# ── per-line acquisition overrides (step=/exptime=/frames=) ────────────────
|
|
|
|
|
|
def test_overrides_parsed_and_sticky_across_lines(tmp_path):
|
|
path = _write(
|
|
tmp_path,
|
|
"0\n"
|
|
"10 1\n"
|
|
"20 1 step=0.5\n"
|
|
"30 1\n"
|
|
"40 2 step=2 exptime=5 frames=3\n"
|
|
"50 2\n",
|
|
)
|
|
result = read_angle_file(path)
|
|
assert result.angles == [
|
|
_e(0.0),
|
|
_e(10.0),
|
|
_e(20.0, step=0.5),
|
|
_e(30.0, step=0.5),
|
|
_e(40.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3),
|
|
_e(50.0, 2, step=2.0, exp_time=5.0, frames_per_trigger=3),
|
|
]
|
|
|
|
|
|
def test_overrides_independent_sticky_state(tmp_path):
|
|
"""Each of step/exptime/frames carries forward independently -- setting
|
|
one doesn't reset or require the others."""
|
|
path = _write(tmp_path, "0 1 step=1\n10 1 exptime=2\n20 1 frames=4\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [
|
|
_e(0.0, step=1.0),
|
|
_e(10.0, step=1.0, exp_time=2.0),
|
|
_e(20.0, step=1.0, exp_time=2.0, frames_per_trigger=4),
|
|
]
|
|
|
|
|
|
def test_overrides_compose_with_subtomo_and_comma_separators(tmp_path):
|
|
path = _write(tmp_path, "10, 2, step=0.3\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(10.0, 2, step=0.3)]
|
|
|
|
|
|
def test_overrides_compose_with_end_sentinel(tmp_path):
|
|
path = _write(tmp_path, "0 1 step=0.5\n10\nEND\n")
|
|
result = read_angle_file(path)
|
|
assert result.angles == [_e(0.0, step=0.5), _e(10.0, step=0.5)]
|
|
assert result.is_complete is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text, match",
|
|
[
|
|
("10 foo=1\n", "unknown key 'foo'"),
|
|
("10 step=1 step=2\n", "duplicate key 'step'"),
|
|
("10 step=\n", "malformed"),
|
|
("10 =5\n", "malformed"),
|
|
("10 step=abc\n", "invalid step"),
|
|
("10 frames=abc\n", "invalid frames"),
|
|
("10 step=0.01\n", r"outside \[0.025, 20\]"),
|
|
("10 step=21\n", r"outside \[0.025, 20\]"),
|
|
("10 exptime=0.0001\n", r"outside \[0.001, 100\]"),
|
|
("10 exptime=200\n", r"outside \[0.001, 100\]"),
|
|
("10 frames=0\n", r"outside \[1, 100\]"),
|
|
("10 frames=101\n", r"outside \[1, 100\]"),
|
|
],
|
|
)
|
|
def test_override_errors(tmp_path, text, match):
|
|
path = _write(tmp_path, text)
|
|
with pytest.raises(AngleFileError, match=match):
|
|
read_angle_file(path)
|
|
|
|
|
|
def test_summary_reports_override_count():
|
|
entries = [_e(0.0), _e(10.0, step=0.5), _e(20.0, exp_time=2.0)]
|
|
summary = summarize_angles(entries)
|
|
assert summary["n_overridden"] == 2
|
|
assert "2 with per-line overrides" in describe_summary(summary)
|
|
|
|
|
|
def test_summary_omits_override_note_when_none_overridden():
|
|
summary = summarize_angles([_e(0.0), _e(10.0)])
|
|
assert summary["n_overridden"] == 0
|
|
assert "overrides" not in describe_summary(summary)
|