CI for csaxs_bec / test (push) Successful in 3m7s
New angular sampling mode: tomo_angle_file points to a plain-text file with one angle (0-360 deg) per line, optional 2nd column = sub-tomogram number, '#' comments. Only the path is stored; the file is re-read at every tomo_scan() start, resume and queued-job run, so an edited file always takes effect on the next (re)start. The parsed angle list is logged to scilog at scan start. - OMNY_shared/angle_file.py: shared pure parser + summary, incl. warning for 180-deg-opposite/duplicate angles - flomni: tomo_type 4 branch in tomo_scan()/tomo_scan_resume(), bad file fails before any acquisition, angles >180 not gated by tomo_angle_range, CLI prompt/display, PDF report - tomo queue: path snapshotted per job; setter does not validate so stale paths never break non-type-4 jobs; reacquire bounds by file - TomoParams widget: type 4 (flomni only) with Browse... file dialog, live content preview, validation blocking Submit/Add to queue - webpage: show angle file in param tables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
3.5 KiB
Python
108 lines
3.5 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 (
|
|
AngleFileError,
|
|
describe_opposite_pairs,
|
|
describe_summary,
|
|
read_angle_file,
|
|
summarize_angles,
|
|
)
|
|
|
|
|
|
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")
|
|
assert read_angle_file(path) == [(0.0, 1), (45.5, 1), (360.0, 1)]
|
|
|
|
|
|
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) == [(0.0, 1), (10.0, 1), (190.5, 2), (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) == [(90.0, 2), (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") == [(12.0, 1)]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text, match",
|
|
[
|
|
("-0.1\n", "outside"),
|
|
("360.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([(0.0, 1), (45.0, 1), (100.0, 3)])
|
|
assert summary["n"] == 3
|
|
assert summary["min"] == 0.0
|
|
assert summary["max"] == 100.0
|
|
assert summary["n_subtomos"] == 2
|
|
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 = [(10.0, 1), (190.0, 1), (50.0, 1), (50.0, 2), (120.0, 1)]
|
|
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([(0.0, 1), (90.0, 1), (359.99, 1)])["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 = [(float(a), 1) 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 = [(float(a), 1) for a in range(0, 360, 10)]
|
|
assert len(summarize_angles(entries)["opposite_pairs"]) == 18
|