Files
Jungfraujoch/tools/battery/test_score.py
T
leonarski_f 6dfe065365
Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m27s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m15s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m44s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m1s
Build Packages / build:windows:nocuda (push) Successful in 17m29s
Build Packages / build:windows:cuda (push) Successful in 19m58s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m8s
Build Packages / build:rugnux:windows (push) Successful in 10m58s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m46s
Build Packages / Generate python client (push) Successful in 53s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m13s
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m57s
Build Packages / build:rpm (rocky8) (push) Successful in 18m7s
Build Packages / build:rpm (rocky9) (push) Successful in 18m54s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m32s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m30s
Build Packages / Unit tests (push) Successful in 1h39m2s
v1.0.0-rc.172 (#82)
* Fixed `jfjoch_broker` cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection.
* Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; `--scaling-iterations` is now the cap on that loop (default 100).
* Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused.
* Every rotation run writes two diagnostic files beside its reflections: `<prefix>_detector.jpg`, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and `<prefix>_plot.txt`, one row per image.

Reviewed-on: #82
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-22 06:48:37 +02:00

146 lines
7.5 KiB
Python

"""Targeted tests of score.py, the part a wrong answer would be laundered through.
Plain stdlib unittest, so it runs with `python3 tools/battery/test_score.py` (or
`python3 -m unittest`) from this directory, without pytest and without a battery run.
"""
import unittest
import score
def report(sg, sgno, cell, **extra):
"""The three report keys the scorer needs, plus whatever a case adds."""
return dict({"SPACE_GROUP_NAME": sg, "SPACE_GROUP_NUMBER": str(sgno),
"UNIT_CELL_CONSTANTS": " ".join(f"{x:.3f}" for x in cell)}, **extra)
# A deposited trigonal reference and the higher group our reduction reads on it: the shape of
# the accepted-alternative rows, with a synthetic cell, not a real one.
TRIGONAL = [80.0, 80.0, 150.0, 90.0, 90.0, 120.0]
DEPOSITED = {"sg": "P 32", "sgno": 145, "cell": TRIGONAL, "dmin": 2.0}
def entry(**extra):
return dict({"id": "test_set", "arm": "open", "ref": DEPOSITED}, **extra)
class AcceptedAlternatives(unittest.TestCase):
def test_deposition_still_decides_on_its_own(self):
"""Without alternatives the higher group is a symmetry failure, as before."""
r = score.judge(entry(), report("P 31 2 1", 152, TRIGONAL), "")
self.assertEqual((r["verdict"], r["cause"]), ("fail", "sym_over"))
def test_either_answer_passes(self):
"""Both the deposited group and the alternative pass, and only the second is marked."""
e = entry(ref_alternatives=[{"sg": "P 31 2 1", "sgno": 152, "why": "a stated reason"}])
dep = score.judge(e, report("P 32", 145, TRIGONAL), "")
self.assertEqual((dep["verdict"], dep["cause"], dep["accepted_alt"]), ("pass", None, None))
alt = score.judge(e, report("P 31 2 1", 152, TRIGONAL), "")
self.assertEqual((alt["verdict"], alt["cause"]), ("pass", "accepted_alternative"))
self.assertEqual(alt["accepted_alt"], "P 31 2 1")
self.assertIn("a stated reason", alt["accepted_alt_why"])
# the row still describes the answer against the DEPOSITION, not against the alternative
self.assertEqual(alt["sg_ref"], "P 32")
def test_the_other_hand_of_an_alternative_passes_too(self):
"""P 31 2 1 and P 32 2 1 differ by hand only, which intensities cannot decide."""
e = entry(ref_alternatives=[{"sg": "P 31 2 1", "sgno": 152, "why": "a stated reason"}])
r = score.judge(e, report("P 32 2 1", 154, TRIGONAL), "")
self.assertEqual((r["verdict"], r["cause"]), ("pass", "accepted_alternative"))
def test_a_third_answer_still_fails(self):
"""An alternative accepts one other answer, not any other answer."""
e = entry(ref_alternatives=[{"sg": "P 31 2 1", "sgno": 152, "why": "a stated reason"}])
r = score.judge(e, report("P 31 1 2", 151, TRIGONAL), "")
self.assertEqual(r["verdict"], "fail")
def test_alternative_cell(self):
"""A supercell alternative: the deposited cell is its (0,1/2,1/2) sublattice, so the
deposition alone scores the answer as a doubled lattice and the alternative accepts it."""
sub = [35.869, 39.297, 100.916, 98.3, 90.32, 90.09]
sup = [35.869, 39.297, 199.976, 87.087, 90.341, 90.09] # c' = b + 2c, centring removed
e = {"id": "test_cell", "arm": "open", "ref": {"sg": "P 1", "sgno": 1, "cell": sub}}
r = score.judge(e, report("P 1", 1, sup), "")
self.assertEqual((r["verdict"], r["cause"]), ("fail", "lattice_doubled"))
e["ref_alternatives"] = [{"cell": sup, "why": "a stated reason"}]
r = score.judge(e, report("P 1", 1, sup), "")
self.assertEqual((r["verdict"], r["cause"]), ("pass", "accepted_alternative"))
self.assertTrue(r["accepted_alt"].startswith("cell "))
self.assertEqual(r["volume_ratio"], 2.0) # still reported against the deposition
def test_a_justification_is_mandatory(self):
"""An accepted alternative with no reason is a schema error, not a silent pass: this is
the check that keeps the mechanism from becoming a way to launder a failure."""
for alt in ({"sg": "P 31 2 1", "sgno": 152},
{"sg": "P 31 2 1", "sgno": 152, "why": " "}):
with self.assertRaises(ValueError) as cm:
score.judge(entry(ref_alternatives=[alt]), report("P 31 2 1", 152, TRIGONAL), "")
self.assertIn("why", str(cm.exception))
def test_an_alternative_must_replace_something(self):
with self.assertRaises(ValueError):
score.judge(entry(ref_alternatives=[{"why": "a stated reason"}]),
report("P 32", 145, TRIGONAL), "")
def test_the_manifest_rows_are_valid(self):
"""Every committed row's alternatives pass the schema check."""
import json
import os
for name in ("open.json", "inhouse.json"):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), name)
with open(path) as f:
for e in json.load(f)["sets"]:
score.alternatives(e)
# A screw along an axis the sweep never recorded: an unanswerable question, not a wrong answer.
# The cells are synthetic, and the second is the first cyclically permuted.
ORTHO = [97.0, 108.0, 113.0, 90.0, 90.0, 90.0]
ORTHO_PERMUTED = [113.0, 97.0, 108.0, 90.0, 90.0, 90.0]
class ScrewUndetermined(unittest.TestCase):
def judge(self, ref_cell, undetermined, alternatives):
"""Our P 21 21 21 on ORTHO against a deposited P 21 21 2 on ref_cell."""
e = {"id": "test_screw", "arm": "open",
"ref": {"sg": "P 21 21 2", "sgno": 18, "cell": ref_cell}}
return score.judge(e, report("P 21 21 21", 19, ORTHO,
SPACE_GROUP_SCREW_UNDETERMINED=undetermined,
SPACE_GROUP_ALTERNATIVES=alternatives), "")
def test_same_setting(self):
"""The plain case: the reference is offered verbatim and the open axis is the one they
differ on."""
r = self.judge(ORTHO, "c", "P 21 21 2")
self.assertEqual((r["verdict"], r["cause"]), ("unscored", "screw_undetermined"))
def test_permuted_setting(self):
"""The deposition's axes are a cyclic permutation of ours, so it names the open screw c
while we name it b. The comparison must be made in OUR setting - against the offered
group of the reference's number - or the rule misses a row it covers."""
r = self.judge(ORTHO_PERMUTED, "b", "P 21 2 21")
self.assertEqual((r["verdict"], r["cause"]), ("unscored", "screw_undetermined"))
self.assertIn("the screw along b", r["reason"])
def test_a_wrong_screw_still_fails(self):
"""The open axis is not the axis the two differ on: in our own setting the disagreement
sits on b while the run says it could not determine a, so the b row WAS measured. That is
a wrong answer and stays one - the new framing must not turn every permutation into an
excuse."""
r = self.judge(ORTHO_PERMUTED, "a", "P 21 2 21")
self.assertEqual((r["verdict"], r["cause"]), ("fail", "sym_screw"))
def test_nothing_undetermined_still_fails(self):
"""Every axial row measured: no rule to apply."""
r = self.judge(ORTHO, "NONE", "")
self.assertEqual((r["verdict"], r["cause"]), ("fail", "sym_screw"))
def test_the_reference_must_be_among_the_offered(self):
"""An open axis alone does not excuse a group the run never offered."""
r = self.judge(ORTHO, "b", "P 21 21 21")
self.assertEqual((r["verdict"], r["cause"]), ("fail", "sym_screw"))
if __name__ == "__main__":
unittest.main()