v1.0.0-rc.166 (#76)
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s

* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
This commit was merged in pull request #76.
This commit is contained in:
2026-09-02 21:17:31 +02:00
committed by leonarski_f
parent 511be0c366
commit 680c36c20d
383 changed files with 20910 additions and 3936 deletions
+148 -1
View File
@@ -240,7 +240,7 @@ TEST_CASE("SearchSpaceGroup finds a screw axis whose absent class is weak only w
// itself and symmetry cannot fill it in. Counting the class therefore measures the geometry of the
// sweep, not the strength of the evidence, and a count gate refused a monoclinic crystal its 2_1 for
// having six 0k0-odd reflections rather than eight, every one of them measured at a thousandth of the
// row beside them. The class is judged by ScrewAbsenceEvidence instead, which reads the contrast
// row beside them. The class is judged by ScrewZoneEvidence instead, which reads the contrast
// against the row - so few-but-decisive is accepted and many-but-marginal is not.
TEST_CASE("SearchSpaceGroup weighs a screw's absences by evidence, not by how many were recorded") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 1 21 1");
@@ -367,3 +367,150 @@ TEST_CASE("SearchSpaceGroup operator correlation reads symmetry, not the resolut
CHECK(std::fabs(two_fold_cc(0.0) - two_fold_cc(6.0)) < 0.05);
}
}
// The enumeration reaches the settings gemmi does not call the reference one, and only when the cell
// has the axes they name. Both halves are pinned here: `P 1 1 2_1` puts its 2-fold and its screw on
// c, which no reference setting can express (Stage A never offers the rotation and Stage B never
// offers the group), so without the two options the answer is P1; with them, and with a cell whose
// unique axis IS c, it is named; and with a cell whose unique axis is b the same candidate is
// refused rather than adopted on axes the crystal does not have.
TEST_CASE("SearchSpaceGroup names a non-reference setting only on a cell that hosts it") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 1 1 21");
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 12);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
SECTION("narrow enumeration cannot name it") {
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->number == 1);
}
SECTION("widened enumeration names it on a c-unique cell") {
opt.cell = gemmi::UnitCell(40.0, 50.0, 60.0, 90.0, 90.0, 100.0);
opt.enumerate_all_settings = true;
opt.enumerate_all_rotation_sets = true;
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->xhm() == "P 1 1 21");
}
SECTION("a b-unique cell refuses it") {
opt.cell = gemmi::UnitCell(40.0, 50.0, 60.0, 90.0, 100.0, 90.0);
opt.enumerate_all_settings = true;
opt.enumerate_all_rotation_sets = true;
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->number == 1);
}
}
// The screw axes of an orthorhombic crystal can lie on any pair of axes, and only one of the three
// namings of #18 is a reference setting. With the narrow enumeration the group that predicts a
// SUBSET of the real absences and nothing else wins on no evidence at all, so the reported group is
// wrong rather than low - the widening is what makes the correct one available.
TEST_CASE("SearchSpaceGroup names an orthorhombic screw pair on the axes it lies on") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 2 21 21");
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 14);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
opt.lattice_system = gemmi::CrystalSystem::Orthorhombic;
SECTION("narrow enumeration reports the wrong group") {
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->number != 18);
}
SECTION("widened enumeration reports it") {
opt.cell = gemmi::UnitCell(40.0, 50.0, 60.0, 90.0, 90.0, 90.0);
opt.enumerate_all_settings = true;
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->xhm() == "P 2 21 21");
}
}
// The centring half of the same widening. A, B and C centring on one orthorhombic cell are three
// different lattices, and only C is a reference setting, so an A-centred crystal used to have its
// centring refused (its absent class is not the one C predicts) and came out primitive. The
// candidate is now offered, and it has to be adopted from its own absences rather than from the
// metric, which cannot tell A from C at all.
TEST_CASE("SearchSpaceGroup names an A-centred orthorhombic lattice") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("A 2 2 2");
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 12);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
opt.lattice_system = gemmi::CrystalSystem::Orthorhombic;
SECTION("narrow enumeration cannot name it") {
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->centring_type() != 'A');
}
SECTION("widened enumeration names it") {
opt.cell = gemmi::UnitCell(40.0, 50.0, 60.0, 90.0, 90.0, 90.0);
opt.enumerate_all_settings = true;
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->xhm() == "A 2 2 2");
}
}
// The null the widening has to survive. Stage A's second pass offers the a- and c-unique 2-folds on
// any metric that could host them, which is every orthorhombic one - so a genuinely triclinic
// crystal sitting on a pseudo-orthorhombic cell is now offered three promotions where it used to be
// offered one. It must still be refused all three: the added candidates go through the same operator
// correlation as every other, and a rotation the intensities do not have scores nothing.
TEST_CASE("SearchSpaceGroup does not promote triclinic data on a pseudo-orthorhombic cell") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 1");
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 10);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
opt.lattice_system = gemmi::CrystalSystem::Orthorhombic;
opt.cell = gemmi::UnitCell(40.0, 50.0, 60.0, 90.0, 90.0, 90.0);
opt.enumerate_all_settings = true;
opt.enumerate_all_rotation_sets = true;
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->number == 1);
CHECK(result.point_group_order == 1);
}
// A zone whose predicted absences were never measurable must not outscore a zone that is genuinely
// dead. sum_u is a sum of max(0, E^2)/row_mean, so it is EXACTLY zero when every absent reflection in
// the zone merged non-positive - and the Beta tail then diverges, worth ~690 nats per reflection. That
// was harmless while the number only had to clear a bound; it is now summed across zones and ranks the
// candidates, so it made a candidate claiming a screw on an UNMEASURED row beat one whose rows are
// actually dead. The evidence is scored through the same entry point for both kinds of absence.
TEST_CASE("AbsenceEvidence does not reward a zone that was never measurable", "[SearchSpaceGroup]") {
// 2 absences that all merged non-positive, against a control of 8...
const double unmeasurable = AbsenceEvidence(0.0, 2, 8);
// ...against a genuinely dead zone: 6 absences at 1% of their row's mean, same control.
const double genuine = AbsenceEvidence(0.06, 6, 8);
CHECK(std::isfinite(unmeasurable));
CHECK(unmeasurable < genuine); // the ordering that was inverted
CHECK(unmeasurable < 20.0); // and it does not clear min_screw_absence_evidence
// The floor is far below any real measurement, so a genuine zone is untouched by it.
CHECK(genuine == Catch::Approx(22.0).margin(0.2));
CHECK(AbsenceEvidence(0.22, 22, 8) == Catch::Approx(65.4).margin(0.3));
// More dead reflections still means more evidence, which is the property the sum relies on.
CHECK(AbsenceEvidence(0.0, 6, 8) > AbsenceEvidence(0.0, 2, 8));
}