From 8f3ba8d38946705d06a2397ac67da270bcd1c174 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 8 Nov 2026 11:19:08 +0100 Subject: [PATCH] rugnux live: a cancelled rotation run reports CANCELLED, counted in images received A cancelled collection showed verdict FAILED, "No crystal lattice was found in these frames" and "cancelled after 0 images", with step titles carrying the planned image counts. The live status now has verdict CANCELLED, says how many images came before the cancel, and a step title's ", N images" is capped at the images received once the run is cut by a cancel. The report of a rugnux CLI run is unchanged. The frontend shows CANCELLED as info, not as an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK --- frontend/src/components/RugnuxParts.tsx | 2 ++ receiver/JFJochReceiverRugnuxRotation.cpp | 16 +++++++++++-- receiver/RugnuxLiveStatus.cpp | 17 +++++++++++++ receiver/RugnuxLiveStatus.h | 3 ++- tests/JFJochReceiverRugnuxTest.cpp | 8 +++++++ tests/RugnuxLiveStatusTest.cpp | 29 +++++++++++++++++++++++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/RugnuxParts.tsx b/frontend/src/components/RugnuxParts.tsx index 6a8a9105e..7dfce46b0 100644 --- a/frontend/src/components/RugnuxParts.tsx +++ b/frontend/src/components/RugnuxParts.tsx @@ -11,6 +11,8 @@ export const verdictSeverity = (verdict: string) => { return "success"; case "WARNINGS": return "warning"; + case "CANCELLED": + return "info"; default: return "error"; } diff --git a/receiver/JFJochReceiverRugnuxRotation.cpp b/receiver/JFJochReceiverRugnuxRotation.cpp index a3ed3aa4f..c30fdd475 100644 --- a/receiver/JFJochReceiverRugnuxRotation.cpp +++ b/receiver/JFJochReceiverRugnuxRotation.cpp @@ -254,12 +254,24 @@ void JFJochReceiverRugnuxRotation::RugnuxThread() { // The verdict and summary of the report a `rugnux` run writes, without writing it. const ReportDocument doc = BuildReportDocument(experiment.GetFilePrefix(), experiment.GetFilePrefix(), setup.experiment, result); + // A cancelled collection is no failed analysis, and the run stops at the images that came + const int64_t images_received = live_status.Get().images_received; + std::string verdict = doc.verdict; + std::string verdict_text = doc.verdict_text; + if (result.cancelled) { + verdict = "CANCELLED"; + verdict_text = fmt::format("The collection was cancelled after {} images - these results describe " + "an incomplete sweep.", images_received); + } std::vector warnings; for (const auto &warning: doc.warnings) - if (!warning.developer_only) + if (result.cancelled && warning.code == PathologyCode::CANCELLED) + warnings.push_back(fmt::format("Processing was cancelled after {} images - this report describes " + "an incomplete run", images_received)); + else if (!warning.developer_only) warnings.push_back(warning.text); const double cc_anom = result.merge_statistics.overall.cc_anom; - live_status.Result(doc.verdict, doc.verdict_text, doc.summary_rows, warnings, + live_status.Result(verdict, verdict_text, doc.summary_rows, warnings, std::isfinite(cc_anom) && cc_anom > CC_ANOM_SIGNAL); if (const auto live_model = LiveModel(result)) live_status.Model(*live_model); diff --git a/receiver/RugnuxLiveStatus.cpp b/receiver/RugnuxLiveStatus.cpp index 9e900acd3..9569af83f 100644 --- a/receiver/RugnuxLiveStatus.cpp +++ b/receiver/RugnuxLiveStatus.cpp @@ -4,6 +4,7 @@ #include "RugnuxLiveStatus.h" #include +#include #include #include "../rugnux/Rugnux.h" @@ -16,6 +17,19 @@ double RugnuxLiveStatus::Now() const { return Since(std::chrono::steady_clock::now()); } +// A step of the run, "..., N images", covers no more images than came: after a cancel the run goes on over those. +void RugnuxLiveStatus::CapImages(std::string &title) const { + const std::string suffix = " images"; + const size_t pos = title.rfind(", "); + if (snapshot.cut != "cancel" || pos == std::string::npos || !title.ends_with(suffix)) + return; + const std::string count = title.substr(pos + 2, title.size() - pos - 2 - suffix.size()); + if (count.empty() || !std::all_of(count.begin(), count.end(), ::isdigit)) + return; + if (std::stoll(count) > snapshot.images_received) + title = title.substr(0, pos + 2) + std::to_string(snapshot.images_received) + suffix; +} + void RugnuxLiveStatus::CloseStep() { if (!snapshot.steps.empty() && !snapshot.steps.back().duration_s) snapshot.steps.back().duration_s = Now() - snapshot.steps.back().started_s; @@ -47,6 +61,7 @@ void RugnuxLiveStatus::Step(const std::string &title) { std::unique_lock ul(status_mutex); CloseStep(); snapshot.steps.push_back(RugnuxLiveStep{.title = title, .started_s = Now()}); + CapImages(snapshot.steps.back().title); snapshot.step_progress.reset(); } @@ -117,6 +132,8 @@ void RugnuxLiveStatus::Cut(const std::string &reason) { std::unique_lock ul(status_mutex); snapshot.cut = reason; snapshot.cut_s = Now(); + for (auto &step: snapshot.steps) + CapImages(step.title); } void RugnuxLiveStatus::Probe(const std::string &text) { diff --git a/receiver/RugnuxLiveStatus.h b/receiver/RugnuxLiveStatus.h index 5c372c286..5f7417282 100644 --- a/receiver/RugnuxLiveStatus.h +++ b/receiver/RugnuxLiveStatus.h @@ -111,7 +111,7 @@ struct RugnuxLiveSnapshot { std::string cut; std::optional cut_s; std::optional last_image_to_written_s; - std::string verdict; // OK | WARNINGS | UNUSABLE | FAILED + std::string verdict; // OK | WARNINGS | UNUSABLE | FAILED | CANCELLED std::string verdict_text; std::vector> summary; std::vector warnings; @@ -136,6 +136,7 @@ class RugnuxLiveStatus { double Since(std::chrono::steady_clock::time_point t) const; double Now() const; void CloseStep(); + void CapImages(std::string &title) const; public: // start_received, received: when the message came off the socket; expected_collection_s: none if not known void Start(int64_t images_expected, std::optional expected_collection_s, diff --git a/tests/JFJochReceiverRugnuxTest.cpp b/tests/JFJochReceiverRugnuxTest.cpp index def6341d9..018e8d72f 100644 --- a/tests/JFJochReceiverRugnuxTest.cpp +++ b/tests/JFJochReceiverRugnuxTest.cpp @@ -698,6 +698,14 @@ TEST_CASE("JFJochReceiverRugnux_Rotation_Cancel", "[JFJochReceiverRugnux]") { const auto live = service.GetRugnuxStatus(); CHECK(live.cut == "cancel"); CHECK(live.state != RugnuxLiveState::Running); + // A cancelled collection, not a failed analysis, and counted in the images that came + CHECK(live.verdict == "CANCELLED"); + CHECK(live.verdict_text.find("after 2 images") != std::string::npos); + for (const auto &step: live.steps) { + const size_t pos = step.title.rfind(", "); + if (pos != std::string::npos && step.title.ends_with(" images")) + CHECK(std::stoll(step.title.substr(pos + 2)) <= 2); + } } SECTION("A silent cancel lets the collection finish") { Stream stream(x, images); diff --git a/tests/RugnuxLiveStatusTest.cpp b/tests/RugnuxLiveStatusTest.cpp index 1ec13a40b..cdb258357 100644 --- a/tests/RugnuxLiveStatusTest.cpp +++ b/tests/RugnuxLiveStatusTest.cpp @@ -322,3 +322,32 @@ TEST_CASE("RugnuxLiveModel_NonFiniteScores", "[RugnuxLive]") { CHECK(!j["model"]["refinement"]["blobs"][0].contains("nearest_atom_distance_A")); CHECK(j.dump().find("null") == std::string::npos); } + +TEST_CASE("RugnuxLiveStatus_StepsAfterCancel", "[RugnuxLive]") { + // A step covers the images that came: after a cancel, not the ones it was planned for + const auto start = std::chrono::steady_clock::now(); + RugnuxLiveStatus status; + status.Start(1000, 10.0, start); + status.Step("Integration (geometry pre-pass), 600 images"); + for (int i = 0; i < 384; i++) + status.ImageReceived(start + i * 10ms); + status.Step("Pre-scan"); + status.Cut("cancel"); + status.Step("Integration, 1000 images"); + status.Step("Integration, 200 images"); + + const auto s = status.Get(); + REQUIRE(s.steps.size() == 4); + CHECK(s.steps[0].title == "Integration (geometry pre-pass), 384 images"); + CHECK(s.steps[1].title == "Pre-scan"); + CHECK(s.steps[2].title == "Integration, 384 images"); + CHECK(s.steps[3].title == "Integration, 200 images"); +} + +TEST_CASE("RugnuxLiveStatus_StepsAfterDeadline", "[RugnuxLive]") { + RugnuxLiveStatus status; + status.Start(1000, 10.0, std::chrono::steady_clock::now()); + status.Step("Integration, 1000 images"); + status.Cut("deadline"); + CHECK(status.Get().steps[0].title == "Integration, 1000 images"); +}