From dec5416a993f0ed58ecc9fc9cee8a2d36ff3ea66 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 11 Nov 2026 15:16:34 +0100 Subject: [PATCH] Rugnux live status carries the report's pathology checklist (triage) GET /rugnux/status gains an optional `triage` array (rugnux_triage_item: label, codes, severity ok|attention|serious|not_measured, text, detail): the SUMMARY checklist of _report.txt (ReportDocument::checks) line for line, coloured from the same flags - absent = ok, fired = attention, or serious for NO_LATTICE/UNUSABLE_MERGE, undetermined = not_measured. Rotation sets it with the verdict when the run is over (the CANCELLED line in the words the live warnings use); serial at every provisional merge and at the end, from BuildReportDocument on that merge (StillsLiveResult). Tests: the live list equals the report's checks; an unusable merge is serious; a live serial merge leaves powder/ice/twinning/tNCS/anisotropy not measured; OpenAPI conversion; receiver rotation and stills runs carry it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK --- broker/OpenAPIConvert.cpp | 19 ++ broker/gen/model/Rugnux_live_status.cpp | 49 ++++++ broker/gen/model/Rugnux_live_status.h | 10 ++ broker/gen/model/Rugnux_triage_item.cpp | 182 ++++++++++++++++++++ broker/gen/model/Rugnux_triage_item.h | 108 ++++++++++++ broker/jfjoch_api.yaml | 37 ++++ docs/python_client/README.md | 1 + docs/python_client/docs/RugnuxLiveStatus.md | 1 + docs/python_client/docs/RugnuxTriageItem.md | 34 ++++ receiver/JFJochReceiverRugnuxRotation.cpp | 10 +- receiver/RugnuxLiveStatus.cpp | 27 +++ receiver/RugnuxLiveStatus.h | 20 +++ receiver/StillsGPUAnalysis.cpp | 10 ++ tests/CMakeLists.txt | 1 + tests/JFJochReceiverRugnuxTest.cpp | 10 ++ tests/RugnuxLiveTriageTest.cpp | 156 +++++++++++++++++ 16 files changed, 673 insertions(+), 2 deletions(-) create mode 100644 broker/gen/model/Rugnux_triage_item.cpp create mode 100644 broker/gen/model/Rugnux_triage_item.h create mode 100644 docs/python_client/docs/RugnuxTriageItem.md create mode 100644 tests/RugnuxLiveTriageTest.cpp diff --git a/broker/OpenAPIConvert.cpp b/broker/OpenAPIConvert.cpp index 73ef27641..b15439315 100644 --- a/broker/OpenAPIConvert.cpp +++ b/broker/OpenAPIConvert.cpp @@ -1294,6 +1294,25 @@ org::openapitools::server::model::Rugnux_live_status Convert(const RugnuxLiveSna } ret.setSummary(summary); ret.setWarnings(input.warnings); + if (!input.triage.empty()) { + std::vector triage; + for (const auto &in: input.triage) { + org::openapitools::server::model::Rugnux_triage_item item; + item.setLabel(in.label); + item.setCodes(in.codes); + switch (in.severity) { + case RugnuxLiveTriageSeverity::Ok: item.setSeverity("ok"); break; + case RugnuxLiveTriageSeverity::Attention: item.setSeverity("attention"); break; + case RugnuxLiveTriageSeverity::Serious: item.setSeverity("serious"); break; + case RugnuxLiveTriageSeverity::NotMeasured: item.setSeverity("not_measured"); break; + } + item.setText(in.text); + if (!in.detail.empty()) + item.setDetail(in.detail); + triage.push_back(item); + } + ret.setTriage(triage); + } ret.setPlacedModel(input.structure != nullptr); ret.setRefinedModel(input.refined_structure != nullptr); ret.setAnomalousMap(input.structure != nullptr && input.structure->HasAnomalousMap()); diff --git a/broker/gen/model/Rugnux_live_status.cpp b/broker/gen/model/Rugnux_live_status.cpp index 2916b2f0f..ef80698e9 100644 --- a/broker/gen/model/Rugnux_live_status.cpp +++ b/broker/gen/model/Rugnux_live_status.cpp @@ -57,6 +57,7 @@ Rugnux_live_status::Rugnux_live_status() m_Last_image_to_written_sIsSet = false; m_Verdict = ""; m_Verdict_text = ""; + m_TriageIsSet = false; } @@ -156,6 +157,27 @@ bool Rugnux_live_status::validate(std::stringstream& msg, const std::string& pat + i++; + } + } + + } + + if (triageIsSet()) + { + const std::vector& value = m_Triage; + const std::string currentValuePath = _pathPrefix + ".triage"; + + + { // Recursive validation of array elements + const std::string oldValuePath = currentValuePath; + int i = 0; + for (const org::openapitools::server::model::Rugnux_triage_item& value : value) + { + const std::string currentValuePath = oldValuePath + "[" + std::to_string(i) + "]"; + + success = value.validate(msg, currentValuePath + ".triage") && success; + i++; } } @@ -252,8 +274,11 @@ bool Rugnux_live_status::operator==(const Rugnux_live_status& rhs) const && (getWarnings() == rhs.getWarnings()) + && + ((!triageIsSet() && !rhs.triageIsSet()) || (triageIsSet() && rhs.triageIsSet() && getTriage() == rhs.getTriage())) + ; } @@ -306,6 +331,8 @@ void to_json(nlohmann::json& j, const Rugnux_live_status& o) j["verdict_text"] = o.m_Verdict_text; j["summary"] = o.m_Summary; j["warnings"] = o.m_Warnings; + if(o.triageIsSet() || !o.m_Triage.empty()) + j["triage"] = o.m_Triage; } @@ -391,6 +418,11 @@ void from_json(const nlohmann::json& j, Rugnux_live_status& o) j.at("verdict_text").get_to(o.m_Verdict_text); j.at("summary").get_to(o.m_Summary); j.at("warnings").get_to(o.m_Warnings); + if(j.find("triage") != j.end()) + { + j.at("triage").get_to(o.m_Triage); + o.m_TriageIsSet = true; + } } @@ -735,6 +767,23 @@ void Rugnux_live_status::setWarnings(std::vector const& value) { m_Warnings = value; } +std::vector Rugnux_live_status::getTriage() const +{ + return m_Triage; +} +void Rugnux_live_status::setTriage(std::vector const& value) +{ + m_Triage = value; + m_TriageIsSet = true; +} +bool Rugnux_live_status::triageIsSet() const +{ + return m_TriageIsSet; +} +void Rugnux_live_status::unsetTriage() +{ + m_TriageIsSet = false; +} } // namespace org::openapitools::server::model diff --git a/broker/gen/model/Rugnux_live_status.h b/broker/gen/model/Rugnux_live_status.h index 4cd0ba02f..718916dc7 100644 --- a/broker/gen/model/Rugnux_live_status.h +++ b/broker/gen/model/Rugnux_live_status.h @@ -20,6 +20,7 @@ #include "Rugnux_summary_row.h" +#include "Rugnux_triage_item.h" #include #include "Rugnux_live_model.h" #include "Rugnux_live_step.h" @@ -229,6 +230,13 @@ public: /// std::vector getWarnings() const; void setWarnings(std::vector const& value); + /// + /// The pathology checklist of the report, in its order. Rotation: once the run is over; serial: at each provisional merge and at the end. Absent until there is one. + /// + std::vector getTriage() const; + void setTriage(std::vector const& value); + bool triageIsSet() const; + void unsetTriage(); friend void to_json(nlohmann::json& j, const Rugnux_live_status& o); friend void from_json(const nlohmann::json& j, Rugnux_live_status& o); @@ -289,6 +297,8 @@ protected: std::vector m_Warnings; + std::vector m_Triage; + bool m_TriageIsSet; }; diff --git a/broker/gen/model/Rugnux_triage_item.cpp b/broker/gen/model/Rugnux_triage_item.cpp new file mode 100644 index 000000000..f2af0945f --- /dev/null +++ b/broker/gen/model/Rugnux_triage_item.cpp @@ -0,0 +1,182 @@ +/** +* Jungfraujoch +* API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. +* +* The version of the OpenAPI document: 1.0.0-rc.186 +* Contact: filip.leonarski@psi.ch +* +* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). +* https://openapi-generator.tech +* Do not edit the class manually. +*/ + + +#include "Rugnux_triage_item.h" +#include "Helpers.h" + +#include + +namespace org::openapitools::server::model +{ + +Rugnux_triage_item::Rugnux_triage_item() +{ + m_Label = ""; + m_Severity = ""; + m_Text = ""; + m_Detail = ""; + m_DetailIsSet = false; + +} + +void Rugnux_triage_item::validate() const +{ + std::stringstream msg; + if (!validate(msg)) + { + throw org::openapitools::server::helpers::ValidationException(msg.str()); + } +} + +bool Rugnux_triage_item::validate(std::stringstream& msg) const +{ + return validate(msg, ""); +} + +bool Rugnux_triage_item::validate(std::stringstream& msg, const std::string& pathPrefix) const +{ + bool success = true; + const std::string _pathPrefix = pathPrefix.empty() ? "Rugnux_triage_item" : pathPrefix; + + + + /* Codes */ { + const std::vector& value = m_Codes; + const std::string currentValuePath = _pathPrefix + ".codes"; + + + { // Recursive validation of array elements + const std::string oldValuePath = currentValuePath; + int i = 0; + for (const std::string& value : value) + { + const std::string currentValuePath = oldValuePath + "[" + std::to_string(i) + "]"; + + + + i++; + } + } + + } + + return success; +} + +bool Rugnux_triage_item::operator==(const Rugnux_triage_item& rhs) const +{ + return + + + (getLabel() == rhs.getLabel()) + && + + (getCodes() == rhs.getCodes()) + && + + (getSeverity() == rhs.getSeverity()) + && + + (getText() == rhs.getText()) + && + + + ((!detailIsSet() && !rhs.detailIsSet()) || (detailIsSet() && rhs.detailIsSet() && getDetail() == rhs.getDetail())) + + ; +} + +bool Rugnux_triage_item::operator!=(const Rugnux_triage_item& rhs) const +{ + return !(*this == rhs); +} + +void to_json(nlohmann::json& j, const Rugnux_triage_item& o) +{ + j = nlohmann::json::object(); + j["label"] = o.m_Label; + j["codes"] = o.m_Codes; + j["severity"] = o.m_Severity; + j["text"] = o.m_Text; + if(o.detailIsSet()) + j["detail"] = o.m_Detail; + +} + +void from_json(const nlohmann::json& j, Rugnux_triage_item& o) +{ + j.at("label").get_to(o.m_Label); + j.at("codes").get_to(o.m_Codes); + j.at("severity").get_to(o.m_Severity); + j.at("text").get_to(o.m_Text); + if(j.find("detail") != j.end()) + { + j.at("detail").get_to(o.m_Detail); + o.m_DetailIsSet = true; + } + +} + +std::string Rugnux_triage_item::getLabel() const +{ + return m_Label; +} +void Rugnux_triage_item::setLabel(std::string const& value) +{ + m_Label = value; +} +std::vector Rugnux_triage_item::getCodes() const +{ + return m_Codes; +} +void Rugnux_triage_item::setCodes(std::vector const& value) +{ + m_Codes = value; +} +std::string Rugnux_triage_item::getSeverity() const +{ + return m_Severity; +} +void Rugnux_triage_item::setSeverity(std::string const& value) +{ + m_Severity = value; +} +std::string Rugnux_triage_item::getText() const +{ + return m_Text; +} +void Rugnux_triage_item::setText(std::string const& value) +{ + m_Text = value; +} +std::string Rugnux_triage_item::getDetail() const +{ + return m_Detail; +} +void Rugnux_triage_item::setDetail(std::string const& value) +{ + m_Detail = value; + m_DetailIsSet = true; +} +bool Rugnux_triage_item::detailIsSet() const +{ + return m_DetailIsSet; +} +void Rugnux_triage_item::unsetDetail() +{ + m_DetailIsSet = false; +} + + +} // namespace org::openapitools::server::model + diff --git a/broker/gen/model/Rugnux_triage_item.h b/broker/gen/model/Rugnux_triage_item.h new file mode 100644 index 000000000..9bed5573c --- /dev/null +++ b/broker/gen/model/Rugnux_triage_item.h @@ -0,0 +1,108 @@ +/** +* Jungfraujoch +* API to control Jungfraujoch developed by the Paul Scherrer Institute (Switzerland). Jungfraujoch is a data acquisition and analysis system for pixel array detectors, primarly PSI JUNGFRAU. Jungfraujoch uses FPGA boards to acquire data at high data rates. # License Clarification While this API definition is licensed under GPL-3.0, **the GPL copyleft provisions do not apply** when this file is used solely to generate OpenAPI clients or when implementing applications that interact with the API. Generated client code and applications using this API definition are not subject to the GPL license requirements and may be distributed under terms of your choosing. This exception is similar in spirit to the Linux Kernel's approach to userspace API headers and the GCC Runtime Library Exception. The Linux Kernel developers have explicitly stated that user programs that merely use the kernel interfaces (syscalls, ioctl definitions, etc.) are not derivative works of the kernel and are not subject to the terms of the GPL. This exception is intended to allow wider use of this API specification without imposing GPL requirements on applications that merely interact with the API, regardless of whether they communicate through network calls or other mechanisms. +* +* The version of the OpenAPI document: 1.0.0-rc.186 +* Contact: filip.leonarski@psi.ch +* +* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). +* https://openapi-generator.tech +* Do not edit the class manually. +*/ +/* + * Rugnux_triage_item.h + * + * A line of the pathology checklist of `_report.txt` (its SUMMARY), in the report's words. `ok`: looked for and not found; `attention`: its warning fired (its codes are in the report's PATHOLOGY_FLAGS); `serious`: a pathology that leaves no result to use - no lattice, or a merge without signal (the report's verdict FAILED or UNUSABLE); `not_measured`: it could not be looked for on this run. + */ + +#ifndef Rugnux_triage_item_H_ +#define Rugnux_triage_item_H_ + + +#include +#include +#include + +namespace org::openapitools::server::model +{ + +/// +/// A line of the pathology checklist of `_report.txt` (its SUMMARY), in the report's words. `ok`: looked for and not found; `attention`: its warning fired (its codes are in the report's PATHOLOGY_FLAGS); `serious`: a pathology that leaves no result to use - no lattice, or a merge without signal (the report's verdict FAILED or UNUSABLE); `not_measured`: it could not be looked for on this run. +/// +class Rugnux_triage_item +{ +public: + Rugnux_triage_item(); + virtual ~Rugnux_triage_item() = default; + + + /// + /// Validate the current data in the model. Throws a ValidationException on failure. + /// + void validate() const; + + /// + /// Validate the current data in the model. Returns false on error and writes an error + /// message into the given stringstream. + /// + bool validate(std::stringstream& msg) const; + + /// + /// Helper overload for validate. Used when one model stores another model and calls it's validate. + /// Not meant to be called outside that case. + /// + bool validate(std::stringstream& msg, const std::string& pathPrefix) const; + + bool operator==(const Rugnux_triage_item& rhs) const; + bool operator!=(const Rugnux_triage_item& rhs) const; + + ///////////////////////////////////////////// + /// Rugnux_triage_item members + + /// + /// Name of the condition, e.g. \"Twinning\" - the summary row's label + /// + std::string getLabel() const; + void setLabel(std::string const& value); + /// + /// The PATHOLOGY_FLAGS codes the line stands for, e.g. TWINNING + /// + std::vector getCodes() const; + void setCodes(std::vector const& value); + /// + /// + /// + std::string getSeverity() const; + void setSeverity(std::string const& value); + /// + /// The summary row's own words; empty where it has none + /// + std::string getText() const; + void setText(std::string const& value); + /// + /// The report's warnings that fired for it, one per line; absent where none fired + /// + std::string getDetail() const; + void setDetail(std::string const& value); + bool detailIsSet() const; + void unsetDetail(); + + friend void to_json(nlohmann::json& j, const Rugnux_triage_item& o); + friend void from_json(const nlohmann::json& j, Rugnux_triage_item& o); +protected: + std::string m_Label; + + std::vector m_Codes; + + std::string m_Severity; + + std::string m_Text; + + std::string m_Detail; + bool m_DetailIsSet; + +}; + +} // namespace org::openapitools::server::model + +#endif /* Rugnux_triage_item_H_ */ diff --git a/broker/jfjoch_api.yaml b/broker/jfjoch_api.yaml index e02ac297b..4bd336d59 100644 --- a/broker/jfjoch_api.yaml +++ b/broker/jfjoch_api.yaml @@ -2882,6 +2882,36 @@ components: type: string value: type: string + rugnux_triage_item: + type: object + description: | + A line of the pathology checklist of `_report.txt` (its SUMMARY), in the report's words. + `ok`: looked for and not found; `attention`: its warning fired (its codes are in the report's + PATHOLOGY_FLAGS); `serious`: a pathology that leaves no result to use - no lattice, or a merge without + signal (the report's verdict FAILED or UNUSABLE); `not_measured`: it could not be looked for on this run. + required: + - label + - codes + - severity + - text + properties: + label: + type: string + description: Name of the condition, e.g. "Twinning" - the summary row's label + codes: + type: array + description: The PATHOLOGY_FLAGS codes the line stands for, e.g. TWINNING + items: + type: string + severity: + type: string + enum: [ok, attention, serious, not_measured] + text: + type: string + description: The summary row's own words; empty where it has none + detail: + type: string + description: The report's warnings that fired for it, one per line; absent where none fired rugnux_live_model: type: object description: | @@ -3263,6 +3293,13 @@ components: type: array items: type: string + triage: + type: array + description: | + The pathology checklist of the report, in its order. Rotation: once the run is over; serial: at each + provisional merge and at the end. Absent until there is one. + items: + $ref: '#/components/schemas/rugnux_triage_item' zeromq_metadata_settings: type: object required: diff --git a/docs/python_client/README.md b/docs/python_client/README.md index 864762b32..f821bbea6 100644 --- a/docs/python_client/README.md +++ b/docs/python_client/README.md @@ -242,6 +242,7 @@ Class | Method | HTTP request | Description - [RugnuxSettings](docs/RugnuxSettings.md) - [RugnuxStatus](docs/RugnuxStatus.md) - [RugnuxSummaryRow](docs/RugnuxSummaryRow.md) + - [RugnuxTriageItem](docs/RugnuxTriageItem.md) - [ScanResult](docs/ScanResult.md) - [ScanResultImagesInner](docs/ScanResultImagesInner.md) - [SpotFindingSettings](docs/SpotFindingSettings.md) diff --git a/docs/python_client/docs/RugnuxLiveStatus.md b/docs/python_client/docs/RugnuxLiveStatus.md index 76648c31d..406665030 100644 --- a/docs/python_client/docs/RugnuxLiveStatus.md +++ b/docs/python_client/docs/RugnuxLiveStatus.md @@ -34,6 +34,7 @@ Name | Type | Description | Notes **verdict_text** | **str** | | **summary** | [**List[RugnuxSummaryRow]**](RugnuxSummaryRow.md) | | **warnings** | **List[str]** | | +**triage** | [**List[RugnuxTriageItem]**](RugnuxTriageItem.md) | The pathology checklist of the report, in its order. Rotation: once the run is over; serial: at each provisional merge and at the end. Absent until there is one. | [optional] ## Example diff --git a/docs/python_client/docs/RugnuxTriageItem.md b/docs/python_client/docs/RugnuxTriageItem.md new file mode 100644 index 000000000..a682d6c98 --- /dev/null +++ b/docs/python_client/docs/RugnuxTriageItem.md @@ -0,0 +1,34 @@ +# RugnuxTriageItem + +A line of the pathology checklist of `_report.txt` (its SUMMARY), in the report's words. `ok`: looked for and not found; `attention`: its warning fired (its codes are in the report's PATHOLOGY_FLAGS); `serious`: a pathology that leaves no result to use - no lattice, or a merge without signal (the report's verdict FAILED or UNUSABLE); `not_measured`: it could not be looked for on this run. + +## Properties + +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**label** | **str** | Name of the condition, e.g. \"Twinning\" - the summary row's label | +**codes** | **List[str]** | The PATHOLOGY_FLAGS codes the line stands for, e.g. TWINNING | +**severity** | **str** | | +**text** | **str** | The summary row's own words; empty where it has none | +**detail** | **str** | The report's warnings that fired for it, one per line; absent where none fired | [optional] + +## Example + +```python +from jfjoch_client.models.rugnux_triage_item import RugnuxTriageItem + +# TODO update the JSON string below +json = "{}" +# create an instance of RugnuxTriageItem from a JSON string +rugnux_triage_item_instance = RugnuxTriageItem.from_json(json) +# print the JSON string representation of the object +print(RugnuxTriageItem.to_json()) + +# convert the object into a dict +rugnux_triage_item_dict = rugnux_triage_item_instance.to_dict() +# create an instance of RugnuxTriageItem from a dict +rugnux_triage_item_from_dict = RugnuxTriageItem.from_dict(rugnux_triage_item_dict) +``` +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/receiver/JFJochReceiverRugnuxRotation.cpp b/receiver/JFJochReceiverRugnuxRotation.cpp index f1e050443..96980e01d 100644 --- a/receiver/JFJochReceiverRugnuxRotation.cpp +++ b/receiver/JFJochReceiverRugnuxRotation.cpp @@ -274,16 +274,22 @@ void JFJochReceiverRugnuxRotation::RugnuxThread() { verdict_text = fmt::format("The collection was cancelled after {} images - these results describe " "an incomplete sweep.", images_received); } + const std::string cancelled_text = fmt::format("Processing was cancelled after {} images - this report " + "describes an incomplete run", images_received); std::vector warnings; for (const auto &warning: doc.warnings) 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)); + warnings.push_back(cancelled_text); else if (!warning.developer_only) warnings.push_back(warning.text); const double cc_anom = result.merge_statistics.overall.cc_anom; live_status.Result(verdict, verdict_text, doc.summary_rows, warnings, std::isfinite(cc_anom) && cc_anom > CC_ANOM_SIGNAL); + std::vector triage = LiveTriage(doc); + for (auto &t: triage) + if (t.codes == std::vector{PathologyCode::CANCELLED}) + t.text = t.detail = cancelled_text; + live_status.Triage(triage); if (const auto live_model = LiveModel(result)) live_status.Model(*live_model); // The numbers the summary's Space group, Resolution and Signal rows give diff --git a/receiver/RugnuxLiveStatus.cpp b/receiver/RugnuxLiveStatus.cpp index 9569af83f..241b2d538 100644 --- a/receiver/RugnuxLiveStatus.cpp +++ b/receiver/RugnuxLiveStatus.cpp @@ -8,6 +8,7 @@ #include #include "../rugnux/Rugnux.h" +#include "../rugnux/ReportDocument.h" double RugnuxLiveStatus::Since(std::chrono::steady_clock::time_point t) const { return std::chrono::duration(t - start).count(); @@ -98,6 +99,32 @@ void RugnuxLiveStatus::Result(const std::string &verdict, const std::string &ver snapshot.anomalous_signal = anomalous_signal; } +void RugnuxLiveStatus::Triage(const std::vector &triage) { + std::unique_lock ul(status_mutex); + snapshot.triage = triage; +} + +std::vector LiveTriage(const ReportDocument &doc) { + std::vector ret; + for (const auto &c: doc.checks) { + RugnuxLiveTriage t{.label = c.label, .codes = c.codes, .text = c.text, .detail = c.detail}; + switch (c.status) { + case ReportCheck::Status::Absent: + t.severity = RugnuxLiveTriageSeverity::Ok; + break; + case ReportCheck::Status::Present: + t.severity = std::ranges::any_of(c.codes, PathologyIsSerious) ? RugnuxLiveTriageSeverity::Serious + : RugnuxLiveTriageSeverity::Attention; + break; + case ReportCheck::Status::Undetermined: + t.severity = RugnuxLiveTriageSeverity::NotMeasured; + break; + } + ret.push_back(t); + } + return ret; +} + void RugnuxLiveStatus::Model(const RugnuxLiveModel &model) { std::unique_lock ul(status_mutex); snapshot.model = model; diff --git a/receiver/RugnuxLiveStatus.h b/receiver/RugnuxLiveStatus.h index 5f7417282..1766c57ac 100644 --- a/receiver/RugnuxLiveStatus.h +++ b/receiver/RugnuxLiveStatus.h @@ -13,6 +13,7 @@ class RugnuxStructure; // RugnuxStructure.h struct ProcessResult; // rugnux/Rugnux.h +struct ReportDocument; // rugnux/ReportDocument.h // Where the live rugnux run of the last collection is. // Times come from the image puller (when a message came off the socket) and are in seconds since the first image arrived. @@ -76,6 +77,19 @@ struct RugnuxLiveModel { std::optional refinement; }; +// A line of the report's pathology checklist (ReportDocument::checks), as the SUMMARY of _report.txt has it: +// Ok - looked for and not found; Attention - its warning fired; Serious - a pathology that leaves no result +// to use (PathologyIsSerious); NotMeasured - it could not be looked for on this run. +enum class RugnuxLiveTriageSeverity { Ok, Attention, Serious, NotMeasured }; + +struct RugnuxLiveTriage { + std::string label; // the summary row's label, e.g. "Twinning" + std::vector codes; // the PATHOLOGY_FLAGS codes it stands for + RugnuxLiveTriageSeverity severity = RugnuxLiveTriageSeverity::NotMeasured; + std::string text; // the summary row's own words + std::string detail; // the warnings that fired, one per line +}; + // Serial frames analysed on the GPU after a pre-pass (StillsGPUAnalysis) enum class RugnuxLivePrePass { Holding, Running, Done, Failed }; @@ -115,6 +129,8 @@ struct RugnuxLiveSnapshot { std::string verdict_text; std::vector> summary; std::vector warnings; + // The report's pathology checklist, in its order: rotation once the run is over, serial at each merge + std::vector triage; bool anomalous_signal = false; // the summary's "Anomalous" row says the data carry usable signal std::optional model; // where a model was given // The headline numbers of the result, once the run is over @@ -149,6 +165,7 @@ public: void Result(const std::string &verdict, const std::string &verdict_text, const std::vector> &summary, const std::vector &warnings, bool anomalous_signal = false); + void Triage(const std::vector &triage); void Model(const RugnuxLiveModel &model); void Headline(const std::string &space_group, std::optional d_min, std::optional isa); void Latency(double last_image_to_written_s); @@ -165,5 +182,8 @@ public: RugnuxLiveSnapshot Get() const; }; +// The pathology checklist of a report, for the live status +std::vector LiveTriage(const ReportDocument &doc); + // What the run made of the model it was given, for the live status; none where no model was given. std::optional LiveModel(const ProcessResult &result); diff --git a/receiver/StillsGPUAnalysis.cpp b/receiver/StillsGPUAnalysis.cpp index 130ef5342..a38eb878c 100644 --- a/receiver/StillsGPUAnalysis.cpp +++ b/receiver/StillsGPUAnalysis.cpp @@ -9,6 +9,7 @@ #include "../common/ThreadAffinity.h" #include "../image_analysis/MXAnalysisWithoutFPGA.h" #include "../image_analysis/WriteReflections.h" +#include "../rugnux/ResultReport.h" #include "../rugnux/RugnuxDefaults.h" #include "../writer/RugnuxResultBlocks.h" #include "RugnuxStructure.h" @@ -428,6 +429,15 @@ void StillsGPUAnalysis::MergeThread() { } void StillsGPUAnalysis::PublishMerge(const StillsMergeOutput &out, const std::string &title) { + // The pathology checklist of the report `rugnux` writes, for this merge + int64_t analysed, indexed; + { + std::unique_lock ul(live_mutex); + analysed = live.images_analysed; + indexed = live.images_indexed; + } + status.Triage(LiveTriage(BuildReportDocument(experiment.GetFilePrefix(), experiment.GetFilePrefix(), merge_experiment, + StillsLiveResult(merge_experiment, out, analysed, indexed)))); const auto &overall = out.merge.statistics.overall; const std::string text = fmt::format("Merge of {}: {} crystals, {:.2f} A, CC1/2 {:.3f}, R_split {:.3f}, " "completeness {:.1f}%, {:.1f} ({:.1f} s)", title, out.images, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3bb067d40..4b9e98a3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -127,6 +127,7 @@ ADD_EXECUTABLE(jfjoch_test ModuleRefinementTest.cpp LiveCollectionTest.cpp RugnuxLiveStatusTest.cpp + RugnuxLiveTriageTest.cpp StillsGPUAnalysisTest.cpp StillsGPUAnalysisFPGATest.cpp RugnuxStructureTest.cpp diff --git a/tests/JFJochReceiverRugnuxTest.cpp b/tests/JFJochReceiverRugnuxTest.cpp index 17129f8e5..a08fb6f12 100644 --- a/tests/JFJochReceiverRugnuxTest.cpp +++ b/tests/JFJochReceiverRugnuxTest.cpp @@ -193,6 +193,14 @@ TEST_CASE("JFJochReceiverRugnux_Stills", "[JFJochReceiverRugnux]") { CHECK(HasNote(live, "Merge of 50% of the images (provisional)")); CHECK(HasNote(live, "Merge of 75% of the images (provisional)")); CHECK(HasNote(live, "Merge of the collection")); + // The report's checklist of the merge: completeness looked at, powder rings not (no pre-scan here) + const auto triage_line = [&](const std::string &label) { + return std::ranges::find(live.triage, label, &RugnuxLiveTriage::label); + }; + REQUIRE(triage_line("Completeness") != live.triage.end()); + CHECK(triage_line("Completeness")->severity != RugnuxLiveTriageSeverity::NotMeasured); + REQUIRE(triage_line("Powder") != live.triage.end()); + CHECK(triage_line("Powder")->severity == RugnuxLiveTriageSeverity::NotMeasured); REQUIRE(live.stills); CHECK(live.stills->prepass == RugnuxLivePrePass::Done); CHECK(live.stills->images_analysed == images); @@ -693,6 +701,8 @@ TEST_CASE("JFJochReceiverRugnux_Rotation", "[JFJochReceiverRugnux]") { CHECK(live.images_received == images); CHECK(live.state != RugnuxLiveState::Idle); CHECK(live.state != RugnuxLiveState::Running); + // The report's checklist comes with its verdict + CHECK(live.verdict.empty() == live.triage.empty()); } TEST_CASE("JFJochReceiverRugnux_Rotation_DoesNotFit", "[JFJochReceiverRugnux]") { diff --git a/tests/RugnuxLiveTriageTest.cpp b/tests/RugnuxLiveTriageTest.cpp new file mode 100644 index 000000000..167e56d1f --- /dev/null +++ b/tests/RugnuxLiveTriageTest.cpp @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include "../broker/OpenAPIConvert.h" +#include "../receiver/RugnuxLiveStatus.h" +#include "../rugnux/ResultReport.h" +#include "../rugnux/StillsLiveMerge.h" + +namespace { + const RugnuxLiveTriage *Find(const std::vector &triage, const std::string &label) { + for (const auto &t: triage) + if (t.label == label) + return &t; + return nullptr; + } + + // A merge to 2 A whose shells inside the fitted resolution are far from complete + ProcessResult ThinMerge() { + ProcessResult result; + result.images_processed = 100; + result.indexing_rate = 0.9f; + result.consensus_cell = UnitCell{.a = 50.0f, .b = 60.0f, .c = 70.0f, + .alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f}; + result.has_merge_statistics = true; + auto &o = result.merge_statistics.overall; + o.d_max = 50.0f; + o.d_min = 2.00f; + o.unique_reflections = 1000; + o.possible_unique_reflections = 1100; + o.total_observations = 4000; + o.mean_i_over_sigma = 12.0; + o.cc_half = 0.98; + result.resolution_fit_A = 2.0; + MergeStatisticsShell shell; + shell.d_max = 50.0f; + shell.d_min = 2.0f; + shell.cc_half = shell.cc_half_internal = 0.98; + shell.unique_reflections = 300; + shell.possible_unique_reflections = 2000; + result.merge_statistics.shells.push_back(shell); + return result; + } +} + +// The live list is the report's checklist line for line: same labels, codes and words, and its colour read +// off the same flags - so the live view and _report.txt cannot disagree. +TEST_CASE("RugnuxLiveTriage_SameAsReport", "[RugnuxLive]") { + DiffractionExperiment x(DetJF(1)); + const ProcessResult result = ThinMerge(); + const ReportDocument doc = BuildReportDocument("p", "in.h5", x, result); + const auto triage = LiveTriage(doc); + + REQUIRE(triage.size() == doc.checks.size()); + REQUIRE(!triage.empty()); + for (size_t i = 0; i < triage.size(); i++) { + CHECK(triage[i].label == doc.checks[i].label); + CHECK(triage[i].codes == doc.checks[i].codes); + CHECK(triage[i].text == doc.checks[i].text); + CHECK(triage[i].detail == doc.checks[i].detail); + switch (doc.checks[i].status) { + case ReportCheck::Status::Absent: + CHECK(triage[i].severity == RugnuxLiveTriageSeverity::Ok); + break; + case ReportCheck::Status::Present: + CHECK((triage[i].severity == RugnuxLiveTriageSeverity::Attention + || triage[i].severity == RugnuxLiveTriageSeverity::Serious)); + break; + case ReportCheck::Status::Undetermined: + CHECK(triage[i].severity == RugnuxLiveTriageSeverity::NotMeasured); + break; + } + } + + // Every flag of the verdict line is a coloured entry + REQUIRE(!doc.pathology_flags.empty()); + for (const auto &code: doc.pathology_flags) { + bool found = false; + for (const auto &t: triage) + if (std::ranges::find(t.codes, code) != t.codes.end()) + found = found || t.severity == RugnuxLiveTriageSeverity::Attention + || t.severity == RugnuxLiveTriageSeverity::Serious; + CHECK(found); + } + + const auto *completeness = Find(triage, "Completeness"); + REQUIRE(completeness); + CHECK(completeness->severity == RugnuxLiveTriageSeverity::Attention); + CHECK(!completeness->detail.empty()); + const auto *twinning = Find(triage, "Twinning"); + REQUIRE(twinning); + CHECK(twinning->severity == RugnuxLiveTriageSeverity::NotMeasured); +} + +// A merge without signal leaves no result to use: the one red entry +TEST_CASE("RugnuxLiveTriage_UnusableMergeIsSerious", "[RugnuxLive]") { + DiffractionExperiment x(DetJF(1)); + ProcessResult result = ThinMerge(); + result.merge_statistics.overall.cc_half = -0.005; + const ReportDocument doc = BuildReportDocument("p", "in.h5", x, result); + REQUIRE(doc.verdict == "UNUSABLE"); + const auto triage = LiveTriage(doc); + const auto *unusable = Find(triage, "Unusable merge"); + REQUIRE(unusable); + CHECK(unusable->severity == RugnuxLiveTriageSeverity::Serious); + CHECK(Find(triage, "Completeness")->severity == RugnuxLiveTriageSeverity::Attention); +} + +// The live serial merge has no pre-scan and runs no twinning or pseudo-translation analysis: those are not +// measured, and the report says so rather than "no rings detected". +TEST_CASE("RugnuxLiveTriage_StillsMergeClaimsOnlyWhatItMeasured", "[RugnuxLive]") { + DiffractionExperiment x(DetJF(1)); + x.SetUnitCell(UnitCell{.a = 50.0f, .b = 60.0f, .c = 70.0f, .alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f}); + StillsMergeOutput out; + out.merge.statistics = ThinMerge().merge_statistics; + out.merge.statistics.stills = true; + out.merge.resolution_fit_A = 2.0; + out.images = 90; + + const ProcessResult result = StillsLiveResult(x, out, 100, 90); + const auto triage = LiveTriage(BuildReportDocument("p", "in.h5", x, result)); + for (const char *label: {"Powder", "Ice", "Twinning", "Pseudo-symmetry", "Multiple lattices", "Anisotropy"}) { + const auto *t = Find(triage, label); + REQUIRE(t); + CHECK(t->severity == RugnuxLiveTriageSeverity::NotMeasured); + } + CHECK(Find(triage, "Completeness")->severity == RugnuxLiveTriageSeverity::Attention); +} + +TEST_CASE("RugnuxLiveTriage_OpenAPI", "[RugnuxLive]") { + RugnuxLiveStatus status; + status.Start(10, std::nullopt, std::chrono::steady_clock::now()); + CHECK(!Convert(status.Get()).triageIsSet()); + + status.Triage({ + {.label = "Twinning", .codes = {"TWINNING"}, .severity = RugnuxLiveTriageSeverity::Ok, .text = "no indication"}, + {.label = "Completeness", .codes = {"LOW_COMPLETENESS"}, .severity = RugnuxLiveTriageSeverity::Attention, + .text = "40.0 %", .detail = "only 40 % of reciprocal space"}, + {.label = "Unusable merge", .codes = {"UNUSABLE_MERGE"}, .severity = RugnuxLiveTriageSeverity::Serious}, + {.label = "Ice", .codes = {"ICE_RINGS"}, .severity = RugnuxLiveTriageSeverity::NotMeasured, + .text = "not measured"} + }); + const auto api = Convert(status.Get()); + REQUIRE(api.triageIsSet()); + const auto items = api.getTriage(); + REQUIRE(items.size() == 4); + CHECK(items[0].getLabel() == "Twinning"); + CHECK(items[0].getCodes() == std::vector{"TWINNING"}); + CHECK(items[0].getSeverity() == "ok"); + CHECK(!items[0].detailIsSet()); + CHECK(items[1].getSeverity() == "attention"); + CHECK(items[1].getDetail() == "only 40 % of reciprocal space"); + CHECK(items[2].getSeverity() == "serious"); + CHECK(items[3].getSeverity() == "not_measured"); +}