Files
csaxs_bec/tests/tests_bec_ipython_client/test_angle_file.py
T
x01dcandClaude Sonnet 5 d119bd2ce8
CI for csaxs_bec / test (push) Successful in 3m4s
fix(flomni): widen tomo_type-4 angle range to stage overtravel; fix GUI clipping
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
2026-10-01 11:49:49 +02:00

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)