tf_z, rf_z, mr_d_min, the placed model's R-factors, CC, map and anomalous sigmas, the rigid body's move, the refinement's R-factors and rmsds, and the headline d_min are set only where finite (llg_z and null_sigma already were). nlohmann writes NaN/inf as null, which the page's "!== undefined" checks let through to .toFixed(), and which the zod schema rejects for the required blob distance - a blob with no atom within 12 A has an infinite one. That distance is now optional (jfjoch_api.yaml; C++ model and frontend client regenerated), absent then. The model cards test for != null, so a null that arrives anyway does not take the page down. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CeEGgpKdfkCTJb3YNMJcu (cherry picked from commit 62ff026aa507e5bda1daa5aa079f32fb5c21f16e)
324 lines
11 KiB
C++
324 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <cmath>
|
|
|
|
#include "../receiver/RugnuxLiveStatus.h"
|
|
#include "../receiver/JFJochReceiverRugnux.h"
|
|
#include "../broker/OpenAPIConvert.h"
|
|
#include "../image_puller/TestImagePuller.h"
|
|
|
|
using namespace std::chrono_literals;
|
|
|
|
TEST_CASE("RugnuxLiveStatus_TimesFromFirstImage", "[RugnuxLive]") {
|
|
// 11 images at 100 Hz, the first 3 s after START, reported out of order as several forward threads do
|
|
const auto start = std::chrono::steady_clock::now() - 10s;
|
|
const auto first = start + 3s;
|
|
|
|
RugnuxLiveStatus status;
|
|
status.Start(11, 0.11, start);
|
|
for (int i: {1, 0, 2, 3, 4, 5, 6, 7, 8, 10, 9})
|
|
status.ImageReceived(first + i * 10ms);
|
|
|
|
auto s = status.Get();
|
|
CHECK(s.expected_collection_s.value() == Catch::Approx(0.11));
|
|
REQUIRE(s.images_received == 11);
|
|
REQUIRE(s.start_to_first_image_s.value() == Catch::Approx(3.0));
|
|
CHECK(!s.last_image_s);
|
|
// "now" is 10 s after START, 7 s after the first image
|
|
CHECK(s.elapsed_s == Catch::Approx(7.0).margin(0.5));
|
|
|
|
REQUIRE(status.LastImage() == first + 100ms);
|
|
s = status.Get();
|
|
REQUIRE(s.last_image_s.value() == Catch::Approx(0.1));
|
|
CHECK((s.images_received - 1) / s.last_image_s.value() == Catch::Approx(100.0));
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveStatus_BeforeFirstImage", "[RugnuxLive]") {
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, std::nullopt, std::chrono::steady_clock::now() - 2s);
|
|
status.Step("waiting");
|
|
|
|
const auto s = status.Get();
|
|
CHECK(!s.start_to_first_image_s);
|
|
CHECK(!s.expected_collection_s);
|
|
CHECK(s.elapsed_s == 0.0);
|
|
REQUIRE(s.steps.size() == 1);
|
|
CHECK(s.steps[0].started_s <= 0.0);
|
|
CHECK(!status.LastImage());
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveStatus_Clear", "[RugnuxLive]") {
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now() - 2s);
|
|
status.ImageReceived(std::chrono::steady_clock::now() - 1s);
|
|
status.Step("indexing");
|
|
status.Probe("lattice found");
|
|
status.Result("OK", "fine", {{"Space group", "P 1"}}, {"a warning"});
|
|
status.Finish(RugnuxLiveState::Done);
|
|
REQUIRE(status.LastImage());
|
|
|
|
status.Clear();
|
|
const auto s = status.Get();
|
|
CHECK(s.state == RugnuxLiveState::Idle);
|
|
CHECK(s.images_expected == 0);
|
|
CHECK(s.images_received == 0);
|
|
CHECK(s.steps.empty());
|
|
CHECK(s.probe.empty());
|
|
CHECK(s.verdict.empty());
|
|
CHECK(s.summary.empty());
|
|
CHECK(s.warnings.empty());
|
|
CHECK(!s.start_to_first_image_s);
|
|
CHECK(!s.last_image_s);
|
|
CHECK(s.elapsed_s == 0.0);
|
|
CHECK(!status.LastImage());
|
|
}
|
|
|
|
TEST_CASE("TestImagePuller_SetsReceiveTime", "[RugnuxLive]") {
|
|
TestImagePuller puller;
|
|
const auto before = std::chrono::steady_clock::now();
|
|
puller.Put(ImagePullerOutput{});
|
|
const auto given = before - 5s;
|
|
puller.Put(ImagePullerOutput{.received = given});
|
|
|
|
auto out = puller.PollImage();
|
|
REQUIRE(out);
|
|
CHECK(out->received >= before);
|
|
out = puller.PollImage();
|
|
REQUIRE(out);
|
|
CHECK(out->received == given);
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveModel_MolecularReplacement", "[RugnuxLive]") {
|
|
ProcessResult r;
|
|
CHECK(!LiveModel(r));
|
|
|
|
MolecularReplacementResult mr;
|
|
mr.ok = true;
|
|
mr.model_name = "model.cif";
|
|
mr.predicted_model = true;
|
|
mr.decision = "AGREES";
|
|
mr.d_min_search = 2.5;
|
|
MRCandidate c;
|
|
c.space_group = *gemmi::find_spacegroup_by_name("P 1 21 1");
|
|
c.copies = 2;
|
|
c.tf_z = 12.5;
|
|
c.rf_z = 6.0;
|
|
c.null_sigma = 20.0;
|
|
mr.candidates = {MRCandidate{}, c};
|
|
mr.chosen = 1;
|
|
r.model_prior = mr;
|
|
ModelValidationResult v;
|
|
v.ok = true;
|
|
v.r_work = 0.38;
|
|
v.r_free = 0.40;
|
|
v.cc_model_overall = 0.71;
|
|
v.mean_atom_density_sigma = 1.6;
|
|
v.anomalous_sites = {{"SD MET A1", 5.5}};
|
|
r.model_validation = v;
|
|
r.model_placement = "MR";
|
|
|
|
const auto m = LiveModel(r);
|
|
REQUIRE(m);
|
|
CHECK(m->placement == "MR");
|
|
CHECK(m->predicted);
|
|
CHECK(m->mr_space_group == "P 1 21 1");
|
|
CHECK(m->copies.value() == 2);
|
|
CHECK(m->tf_z.value() == Catch::Approx(12.5));
|
|
CHECK(!m->llg_z);
|
|
CHECK(m->fit_sigma.value() == Catch::Approx(20.0)); // MR's null: the rigid body's was not tested
|
|
CHECK(m->r_free.value() == Catch::Approx(0.40));
|
|
CHECK(m->cc_model.value() == Catch::Approx(0.71));
|
|
CHECK(!m->rigid_body_shift_A);
|
|
CHECK(m->anomalous_site == "SD MET A1");
|
|
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now());
|
|
status.Result("OK", "fine", {}, {}, true);
|
|
status.Model(*m);
|
|
nlohmann::json j = Convert(status.Get());
|
|
CHECK(j["anomalous_signal"] == true);
|
|
CHECK(j["model"]["placement"] == "MR");
|
|
CHECK(j["model"]["copies"] == 2);
|
|
CHECK(j["model"]["r_free"].get<double>() == Catch::Approx(0.40));
|
|
CHECK(!j["model"].contains("llg_z"));
|
|
CHECK(!j["model"].contains("rigid_body_angle_deg"));
|
|
|
|
status.Clear();
|
|
CHECK(!status.Get().model);
|
|
CHECK(!status.Get().anomalous_signal);
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveStatus_Headline", "[RugnuxLive]") {
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now());
|
|
nlohmann::json j = Convert(status.Get());
|
|
CHECK(!j.contains("space_group"));
|
|
CHECK(!j.contains("d_min"));
|
|
CHECK(!j.contains("isa"));
|
|
|
|
status.Headline("P 1 21 1", 1.25, 24.2);
|
|
j = Convert(status.Get());
|
|
CHECK(j["space_group"] == "P 1 21 1");
|
|
CHECK(j["d_min"].get<double>() == Catch::Approx(1.25));
|
|
CHECK(j["isa"].get<double>() == Catch::Approx(24.2));
|
|
|
|
// ISa undetermined
|
|
status.Headline("P 1 21 1", 1.25, std::nullopt);
|
|
j = Convert(status.Get());
|
|
CHECK(!j.contains("isa"));
|
|
|
|
status.Clear();
|
|
CHECK(status.Get().space_group.empty());
|
|
CHECK(!status.Get().d_min);
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveModel_Refinement", "[RugnuxLive]") {
|
|
ProcessResult r;
|
|
ModelValidationResult v;
|
|
v.ok = true;
|
|
v.r_work = 0.38;
|
|
v.r_free = 0.40;
|
|
r.model_validation = v;
|
|
r.model_placement = "RIGID_BODY";
|
|
CHECK(!LiveModel(r)->refinement);
|
|
|
|
RefinementResult rf;
|
|
rf.ok = true;
|
|
rf.on_gpu = true;
|
|
rf.seconds = 3.5;
|
|
rf.r_work_placement = 0.30;
|
|
rf.r_free_placement = 0.33;
|
|
rf.restrained = true;
|
|
rf.r_work = 0.19;
|
|
rf.r_free = 0.23;
|
|
rf.geometry.rmsd_bonds = 0.011;
|
|
rf.geometry.rmsd_angles = 1.6;
|
|
DensityBlob b;
|
|
b.kind = DensityBlob::Kind::Ligand;
|
|
b.centroid = gemmi::Position(1.0, 2.0, 3.0);
|
|
b.peak_sigma = 7.5;
|
|
b.volume = 40.0;
|
|
b.nearest_atom = "OG1 THR A42";
|
|
b.nearest_atom_distance = 3.1;
|
|
rf.blobs.assign(RugnuxLiveRefinement::MAX_BLOBS + 5, b);
|
|
r.refinement = rf;
|
|
|
|
const auto m = LiveModel(r);
|
|
REQUIRE(m->refinement);
|
|
const RugnuxLiveRefinement &lr = *m->refinement;
|
|
CHECK(lr.performed);
|
|
CHECK(lr.r_free.value() == Catch::Approx(0.23));
|
|
CHECK(lr.rmsd_bonds_A.value() == Catch::Approx(0.011));
|
|
CHECK(lr.blob_count == static_cast<int64_t>(RugnuxLiveRefinement::MAX_BLOBS + 5));
|
|
CHECK(lr.blobs.size() == RugnuxLiveRefinement::MAX_BLOBS);
|
|
// the placed model's R-factors are untouched
|
|
CHECK(m->r_free.value() == Catch::Approx(0.40));
|
|
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now());
|
|
status.Model(*m);
|
|
nlohmann::json j = Convert(status.Get());
|
|
CHECK(j["refined_model"] == false);
|
|
CHECK(j["model"]["r_free"].get<double>() == Catch::Approx(0.40));
|
|
CHECK(j["model"]["refinement"]["r_free"].get<double>() == Catch::Approx(0.23));
|
|
CHECK(j["model"]["refinement"]["on_gpu"] == true);
|
|
CHECK(j["model"]["refinement"]["blob_count"] == RugnuxLiveRefinement::MAX_BLOBS + 5);
|
|
CHECK(j["model"]["refinement"]["blobs"].size() == RugnuxLiveRefinement::MAX_BLOBS);
|
|
CHECK(j["model"]["refinement"]["blobs"][0]["kind"] == "LIGAND");
|
|
CHECK(j["model"]["refinement"]["blobs"][0]["z"].get<double>() == Catch::Approx(3.0));
|
|
|
|
// Only the placement stage ran: its R-factors, no geometry
|
|
rf.restrained = false;
|
|
r.refinement = rf;
|
|
const auto p = LiveModel(r);
|
|
CHECK(p->refinement->r_free.value() == Catch::Approx(0.33));
|
|
CHECK(!p->refinement->rmsd_bonds_A);
|
|
|
|
// Failed
|
|
RefinementResult failed;
|
|
failed.failure_reason = "no dictionary";
|
|
r.refinement = failed;
|
|
status.Model(*LiveModel(r));
|
|
j = Convert(status.Get());
|
|
CHECK(j["model"]["refinement"]["performed"] == false);
|
|
CHECK(j["model"]["refinement"]["reason"] == "no dictionary");
|
|
CHECK(!j["model"]["refinement"].contains("r_free"));
|
|
}
|
|
|
|
TEST_CASE("RugnuxLiveModel_RefinementCancelled", "[RugnuxLive]") {
|
|
ProcessResult r;
|
|
ModelValidationResult v;
|
|
v.ok = true;
|
|
r.model_validation = v;
|
|
r.model_placement = "MR";
|
|
RefinementResult rf;
|
|
rf.ok = true;
|
|
rf.cancelled = true;
|
|
r.refinement = rf;
|
|
const auto m = LiveModel(r);
|
|
CHECK(m->refinement->cancelled);
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now());
|
|
status.Model(*m);
|
|
const nlohmann::json j = Convert(status.Get());
|
|
CHECK(j["model"]["refinement"]["cancelled"] == true);
|
|
}
|
|
|
|
// A score that is not a number is left out, not written as null: the page reads every present one as a number.
|
|
TEST_CASE("RugnuxLiveModel_NonFiniteScores", "[RugnuxLive]") {
|
|
ProcessResult r;
|
|
MolecularReplacementResult mr;
|
|
mr.ok = true;
|
|
mr.d_min_search = NAN;
|
|
MRCandidate c;
|
|
c.space_group = *gemmi::find_spacegroup_by_name("P 1 21 1");
|
|
c.tf_z = NAN;
|
|
c.rf_z = INFINITY;
|
|
mr.candidates = {c};
|
|
mr.chosen = 0;
|
|
r.model_prior = mr;
|
|
ModelValidationResult v;
|
|
v.ok = true;
|
|
v.r_work = NAN;
|
|
v.r_free = NAN;
|
|
v.mean_atom_density_sigma = NAN;
|
|
r.model_validation = v;
|
|
r.model_placement = "MR";
|
|
RefinementResult rf;
|
|
rf.ok = true;
|
|
rf.restrained = true;
|
|
rf.r_work = NAN;
|
|
rf.r_free = 0.25;
|
|
rf.geometry.rmsd_bonds = NAN;
|
|
DensityBlob b;
|
|
b.nearest_atom_distance = INFINITY;
|
|
rf.blobs = {b};
|
|
r.refinement = rf;
|
|
|
|
const auto m = LiveModel(r);
|
|
REQUIRE(m);
|
|
CHECK(!m->tf_z);
|
|
CHECK(!m->rf_z);
|
|
CHECK(!m->mr_d_min);
|
|
CHECK(!m->r_free);
|
|
CHECK(!m->map_atom_sigma);
|
|
CHECK(!m->refinement->r_work);
|
|
CHECK(m->refinement->r_free.value() == Catch::Approx(0.25));
|
|
CHECK(!m->refinement->rmsd_bonds_A);
|
|
CHECK(!m->refinement->blobs.at(0).nearest_atom_distance_A);
|
|
|
|
RugnuxLiveStatus status;
|
|
status.Start(10, 1.0, std::chrono::steady_clock::now());
|
|
status.Model(*m);
|
|
const nlohmann::json j = Convert(status.Get());
|
|
CHECK(!j["model"].contains("tf_z"));
|
|
CHECK(!j["model"].contains("rf_z"));
|
|
CHECK(!j["model"].contains("r_free"));
|
|
CHECK(!j["model"]["refinement"].contains("r_work"));
|
|
CHECK(!j["model"]["refinement"]["blobs"][0].contains("nearest_atom_distance_A"));
|
|
CHECK(j.dump().find("null") == std::string::npos);
|
|
}
|