From d74ff7fc291ce82ac7ba9c00af973c30f5ffb663 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Wed, 12 Aug 2026 05:13:57 +0200 Subject: [PATCH] Viewer: the settings panel says how the sample moved, and how polarized the beam was Two experiment properties the panel could not state, both of which the analysis it drives has an opinion about anyway. The polarization factor is one number that corrects both communities' output - the azimuthal profile through AzimuthalIntegrationMapping and the integrated intensities through BraggIntegrationEngine - so it goes in the Geometry section, which the MX and AzInt pages already share. Files carry no polarization factor at all, so before this the interactive analysis integrated with none while "Analyze dataset" applied 0.99 from the rugnux defaults: the panel showed nothing and the two front ends disagreed. The viewer's starting experiment now takes the same rugnux defaults, so the panel shows what the analysis actually uses, and a processing job takes the panel's value over the default - as it already did for the scaling fields. The Goniometer section states which of the three things a dataset is - a still, a rotation, or a grid scan - which is exactly the choice a file makes at /entry/sample/transformations/omega vs /entry/sample/grid_scan vs neither. That choice IS the rotation/stills switch, so "Process as stills" is gone from the Indexing section rather than sitting beside it as a second control. It is not tied to what the file says: a still file can be given an axis or a grid, and a rotation file can be processed as stills. The inactive modes grey out but keep their values, so switching away and back does not lose an axis; a file that names none offers omega / -1 0 0 / 0.1 deg and a 10-point, 20 um raster. Only the fast axis of a grid gets a count field, because that is all there is: GridScanSettings derives the slow one from the image count, and the file stores n_fast alone. The grid the settings make is spelled out under them instead. The steps are signed - the sign is the direction the scan runs in - so only zero is rejected, and a half-typed entry falls back to the default rather than throwing out of a widget signal, which would abort the viewer. JFJochReader::UpdateGeomMetadata carries a fixed whitelist of the fields a panel edit may change, and it grew by three. Without them the new controls reset themselves on the next dataset refresh, since a non-whitelisted field comes back as the file's. The function has two call sites, both in the viewer's reading worker, and it is not virtual: objdump on the rugnux binary shows the code linked in and never called, so offline processing is untouched. RugnuxCommandLine emits --force-still from the axis the experiment carries, which the panel now clears when the mode is not Rotation - so the worker remembers the axis the file was opened with, and the copied command line is built with that put back. rugnux has no flag for the axis itself, so an axis edited or invented in the panel still cannot be expressed on a command line; the in-process run gets the experiment object and is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- reader/JFJochReader.cpp | 3 + viewer/JFJochImageReadingWorker.cpp | 4 +- viewer/JFJochImageReadingWorker.h | 6 + viewer/JFJochViewerWindow.cpp | 5 + viewer/widgets/JFJochViewerSettingsDock.cpp | 269 ++++++++++++++++-- viewer/widgets/JFJochViewerSettingsDock.h | 39 ++- viewer/windows/JFJochProcessingJobsWindow.cpp | 27 +- 7 files changed, 318 insertions(+), 35 deletions(-) diff --git a/reader/JFJochReader.cpp b/reader/JFJochReader.cpp index 0ab19292..fb49e358 100644 --- a/reader/JFJochReader.cpp +++ b/reader/JFJochReader.cpp @@ -84,6 +84,9 @@ void JFJochReader::UpdateGeomMetadata(const DiffractionExperiment &experiment) { new_dataset->experiment.PoniRot3_rad(experiment.GetDatasetSettings().GetPoniRot3_rad()); new_dataset->experiment.SetUnitCell(experiment.GetUnitCell()); new_dataset->experiment.SpaceGroupNumber(experiment.GetSpaceGroupNumber()); + new_dataset->experiment.PolarizationFactor(experiment.GetPolarizationFactor()); + new_dataset->experiment.Goniometer(experiment.GetGoniometer()); + new_dataset->experiment.GridScan(experiment.GetGridScan()); new_dataset->experiment.ImportIndexingSettings(experiment.GetIndexingSettings()); new_dataset->experiment.ImportBraggIntegrationSettings(experiment.GetBraggIntegrationSettings()); new_dataset->experiment.DetectIceRings(experiment.IsDetectIceRings()); diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index e132d3e5..11282d2a 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -286,6 +286,7 @@ void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_ if (dataset) { curr_experiment = dataset->experiment; roi_override_.reset(); // new file: use its ROIs, forget any earlier edits + file_goniometer_ = dataset->experiment.GetGoniometer(); // before the panel edits it curr_experiment.ImportIndexingSettings(indexing_settings); curr_experiment.ImportAzimuthalIntegrationSettings(azint_settings); curr_experiment.ImportBraggIntegrationSettings(bragg_settings); @@ -576,7 +577,7 @@ void JFJochImageReadingWorker::UpdateSpotFindingSettings(const SpotFindingSettin indexing_settings.Algorithm(indexing.GetAlgorithm()); indexing_settings.GeomRefinementAlgorithm(indexing.GetGeomRefinementAlgorithm()); - indexing_settings.RotationIndexing(indexing.GetRotationIndexing()); // "Process as stills" drives this + indexing_settings.RotationIndexing(indexing.GetRotationIndexing()); // the rotation axis drives this indexing_settings.Tolerance(indexing.GetTolerance()); indexing_settings.ViableCellMinSpots(indexing.GetViableCellMinSpots()); indexing_settings.IndexIceRings(indexing.GetIndexIceRings()); @@ -1055,6 +1056,7 @@ ReprocessingInputs JFJochImageReadingWorker::GetReprocessingInputs() const { in.experiment = curr_experiment; in.pixel_mask = *current_image_ptr->Dataset().pixel_mask; in.spot_finding = spot_finding_settings; + in.file_goniometer = file_goniometer_; if (reference_) in.reference_data = reference_->reflections; in.valid = true; diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index 28c7180c..8ca1795b 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -51,6 +51,9 @@ struct ReprocessingInputs { PixelMask pixel_mask; SpotFindingSettings spot_finding; std::vector reference_data; // empty unless a reference MTZ is loaded + // The rotation axis the file was opened with, before the settings panel had a say. `experiment` + // carries the panel's, which may have cleared it; this is what a fresh `rugnux` run would see. + std::optional file_goniometer; }; #include "RunData.h" @@ -78,6 +81,9 @@ private: // Once the user edits ROIs they override whatever the file carried, for this and // every subsequently loaded image, until a new file is opened. std::optional roi_override_; + // The rotation axis of the open file, captured before the settings panel can change it — see + // ReprocessingInputs::file_goniometer. + std::optional file_goniometer_; std::map run_labels_; // snapshot id -> editable display label IndexingSettings indexing_settings; AzimuthalIntegrationSettings azint_settings; diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index 939ffafa..3c8cfabb 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -23,6 +23,7 @@ #include "widgets/JFJochViewerImageStrip.h" #include "JFJochViewerStatusBar.h" #include "../common/CUDAWrapper.h" +#include "../rugnux/RugnuxDefaults.h" #include "windows/JFJochViewerImageListWindow.h" #include "windows/JFJochViewerMetadataWindow.h" #ifdef JFJOCH_VIEWER_DBUS @@ -105,6 +106,10 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString DiffractionExperiment experiment; experiment.ImportIndexingSettings(indexing_settings); experiment.DetectIceRings(true); + // Files carry no polarization factor, so without this the interactive analysis would integrate + // without the Lp correction that a processing run applies. Start from the same policy `rugnux` + // does; the settings panel shows it and can change it per dataset. + ApplyRugnuxExperimentDefaults(experiment); // Central area: the diffraction image. Everything else (inspector, plots, processing) is a // dock, so the layout can be rearranged, saved, and switched between perspectives. diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index 41c16b31..6bd9ef5c 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -11,7 +11,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -30,6 +32,17 @@ #include "../../gemmi_gph/gemmi/symmetry.hpp" namespace { + // What the goniometer fields offer when the file describes no sample motion, so picking a mode + // on a still file starts from something sensible: the usual single rotation axis, and a raster + // of the size beamlines commonly use. + const char *DEFAULT_AXIS_NAME = "omega"; + constexpr float DEFAULT_AXIS[3] = {-1.0f, 0.0f, 0.0f}; + constexpr float DEFAULT_INCREMENT_DEG = 0.1f; + constexpr float DEFAULT_GRID_N_FAST = 10.0f; + constexpr float DEFAULT_GRID_STEP_UM = 20.0f; + // Polarized fraction of a synchrotron beam - the same default a bare `rugnux` run applies. + constexpr float DEFAULT_POLARIZATION = 0.99f; + // A single diffraction frame (image) vs a stack of frames (dataset), drawn white so they read on // the navy "Analyze" hero buttons. QIcon FramesIcon(int frames) { @@ -143,6 +156,17 @@ QWidget *JFJochViewerSettingsDock::BuildGeometrySection() { beamY_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "px", this); rot1_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "°", this); rot2_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "°", this); + // Both are overwritten from the experiment as soon as a dataset is open; this is what they show + // until then. + polarizationOn_ = new QCheckBox("Polarization correction", this); + polarizationOn_->setChecked(true); + polarization_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_POLARIZATION, 3, "", this); + const QString polarizationTip = + "Polarized fraction of the beam in the horizontal plane (XDS FRACTION_OF_POLARIZATION): 0.99 " + "for a synchrotron, 0 for an unpolarized source. Divides out the Lp modulation in both the " + "azimuthal profile and the integrated Bragg intensities; unchecked = no correction at all."; + polarizationOn_->setToolTip(polarizationTip); + polarization_->setToolTip(polarizationTip); // The detector origin is the PONI (PyFAI) point; in MX (XDS-style) terms it is the beam origin, // which coincides with the beam center only when the detector is untilted. @@ -161,14 +185,22 @@ QWidget *JFJochViewerSettingsDock::BuildGeometrySection() { auto *tilt = new QHBoxLayout(); tilt->addWidget(rot1_); tilt->addWidget(rot2_); + auto *polarization = new QHBoxLayout(); + polarization->addWidget(polarizationOn_); + polarization->addWidget(polarization_, 1); geom->addRow("Photon energy", energy_); geom->addRow("Detector distance", distance_); geom->addRow("Beam origin", beam); geom->addRow("Detector tilt", tilt); + geom->addRow("", polarization); - for (auto *f : {energy_, distance_, beamX_, beamY_, rot1_, rot2_}) + for (auto *f : {energy_, distance_, beamX_, beamY_, rot1_, rot2_, polarization_}) connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); }); + connect(polarizationOn_, &QCheckBox::toggled, this, [this](bool on) { + polarization_->setEnabled(on); + EmitExperiment(); + }); section->setContentLayout(geom); return section; @@ -226,6 +258,8 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { EmitExperiment(); }); + layout->addWidget(BuildGoniometerSection()); + // --- Spot finding --- auto *spotSection = new CollapsibleSection("Spot finding", page); auto *spot = new QFormLayout(); @@ -334,18 +368,9 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { // (multi-lattice integration). refine->addItem("Flex (best per-image refinement)", static_cast(GeomRefinementAlgorithmEnum::Flex)); refine->setCurrentIndex(refine->findData(static_cast(indexing_.GetGeomRefinementAlgorithm()))); - // One high-level mode switch instead of separate partiality / rot3d / rotation-indexing options: - // for a rotation dataset, unchecked = the rotation good-path (rotation indexing + Rotation - // partiality + rot3d combine + scale-fulls), checked = treat it as stills (fixed partiality, - // per-frame indexing). Disabled (and a no-op) for datasets that are already stills. - stills_ = new QCheckBox("Process as stills", page); - stills_->setEnabled(false); // datasetLoaded enables it only for rotation (goniometer) datasets - stills_->setToolTip("Treat a rotation dataset as independent stills (fixed partiality, per-frame " - "indexing). Unchecked on a rotation dataset = rotation indexing + 3D rotation scaling."); idx->addRow("Algorithm", algo_); idx->addRow("", algoDesc_); idx->addRow("Refinement", refine); - idx->addRow("", stills_); idxSection->setContentLayout(idx); idxSection->setExpanded(false); layout->addWidget(idxSection); @@ -358,7 +383,6 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { indexing_.GeomRefinementAlgorithm(static_cast(refine->currentData().toInt())); EmitSpotFinding(); }); - connect(stills_, &QCheckBox::toggled, this, [this] { ApplyProcessingMode(); }); UpdateAlgorithmDescription(); layout->addWidget(BuildBraggSection()); @@ -369,6 +393,170 @@ QWidget *JFJochViewerSettingsDock::BuildMXPage() { return page; } +QWidget *JFJochViewerSettingsDock::BuildGoniometerSection() { + // How the sample moved between images, in the same three cases a file stores: a rotation axis + // (/entry/sample/transformations/omega), a grid scan (/entry/sample/grid_scan), or neither. The + // mode IS the rotation/stills switch, so there is no second "process as stills" control, and it + // is not tied to what the file says: a still file can be given an axis or a grid, and a rotation + // file can be processed as stills by choosing Still. + auto *section = new CollapsibleSection("Goniometer", this); + auto *form = new QFormLayout(); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + // Exclusive, so radio buttons rather than checkboxes: a dataset is one of the three, never two. + modeStill_ = new QRadioButton("Still", this); + modeStill_->setToolTip("The sample does not move between images: every image is an independent " + "still (per-frame indexing, fixed partiality)."); + modeRotation_ = new QRadioButton("Rotation", this); + modeRotation_->setToolTip("The sample rotates between images: rotation indexing, Ewald-crossing " + "partiality and 3D rotation scaling."); + modeGrid_ = new QRadioButton("Grid scan", this); + modeGrid_->setToolTip("The sample translates between images on a raster. Processed as stills; the " + "grid is what the dataset-info panel maps the per-image results onto."); + auto *modes = new QButtonGroup(this); + modes->setExclusive(true); + for (auto *b : {modeStill_, modeRotation_, modeGrid_}) + modes->addButton(b); + modeStill_->setChecked(true); + + axisName_ = new QLineEdit(DEFAULT_AXIS_NAME, this); + axisName_->setToolTip("Name of the rotated goniometer axis, as it is written to the output."); + axisX_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[0], 3, "", this); + axisY_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[1], 3, "", this); + axisZ_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[2], 3, "", this); + const QString axisTip = "Rotation axis in the laboratory frame (x, y, z); normalised on use."; + for (auto *f : {axisX_, axisY_, axisZ_}) + f->setToolTip(axisTip); + rotStart_ = new NumberLineEdit(-3600.0, 3600.0, 0.0, 3, "°", this); + rotStart_->setToolTip("Angle of the first image."); + rotIncrement_ = new NumberLineEdit(-10.0, 10.0, DEFAULT_INCREMENT_DEG, 3, "°", this); + rotIncrement_->setToolTip("Angle rotated per image."); + + // Only the fast axis has a point count: the slow one is however many rows the images fill, which + // is also why the file stores n_fast alone. The steps are signed - the sign is the direction the + // scan runs in - so only zero is rejected. + gridNFast_ = new NumberLineEdit(1.0, 100000.0, DEFAULT_GRID_N_FAST, 0, "", this); + gridNFast_->setToolTip("Points along the fast axis. The number of slow rows follows from the " + "number of images."); + gridStepX_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "μm", this); + gridStepY_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "μm", this); + const QString stepTip = "Distance between neighbouring points, in x and y. Negative = the scan " + "runs towards decreasing x / y."; + gridStepX_->setToolTip(stepTip); + gridStepY_->setToolTip(stepTip); + gridVertical_ = new QCheckBox("Fast axis vertical", this); + gridVertical_->setToolTip("The scan fills a column before moving to the next one (fast axis = y) " + "instead of filling a row (fast axis = x)."); + gridSnake_ = new QCheckBox("Snake scan", this); + gridSnake_->setToolTip("Every second row/column is collected in the reverse direction (boustrophedon) " + "instead of returning to the start of the next one."); + gridSummary_ = new QLabel(this); + gridSummary_->setStyleSheet("color: gray;"); + + auto *axis = new QHBoxLayout(); + axis->addWidget(axisX_); axis->addWidget(axisY_); axis->addWidget(axisZ_); + auto *step = new QHBoxLayout(); + step->addWidget(gridStepX_); step->addWidget(gridStepY_); + auto *gridFlags = new QHBoxLayout(); + gridFlags->addWidget(gridVertical_); gridFlags->addWidget(gridSnake_); + + form->addRow("", modeStill_); + form->addRow("", modeRotation_); + form->addRow("Axis name", axisName_); + form->addRow("Axis vector", axis); + form->addRow("Start angle", rotStart_); + form->addRow("Increment", rotIncrement_); + form->addRow("", modeGrid_); + form->addRow("Points (fast)", gridNFast_); + form->addRow("Step x, y", step); + form->addRow("", gridFlags); + form->addRow("", gridSummary_); + section->setContentLayout(form); + + EnableRotationFields(false); // datasetLoaded picks the mode the file describes + EnableGridFields(false); + // One handler for the three: whichever is now on decides which fields are live, what the + // experiment carries, and whether the run is a rotation or stills one. + for (auto *b : {modeStill_, modeRotation_, modeGrid_}) + connect(b, &QRadioButton::toggled, this, [this](bool on) { + if (!on) + return; // the button being switched off reports first; act once, on the new mode + EnableRotationFields(modeRotation_->isChecked()); + EnableGridFields(modeGrid_->isChecked()); + UpdateGridSummary(); + ApplyProcessingMode(); + EmitExperiment(); + }); + connect(axisName_, &QLineEdit::editingFinished, this, [this] { EmitExperiment(); }); + for (auto *f : {axisX_, axisY_, axisZ_, rotStart_, rotIncrement_}) + connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); }); + for (auto *f : {gridNFast_, gridStepX_, gridStepY_}) + connect(f, &NumberLineEdit::newValue, this, [this] { UpdateGridSummary(); EmitExperiment(); }); + for (auto *b : {gridVertical_, gridSnake_}) + connect(b, &QCheckBox::toggled, this, [this] { UpdateGridSummary(); EmitExperiment(); }); + + return section; +} + +void JFJochViewerSettingsDock::EnableRotationFields(bool on) { + axisName_->setEnabled(on); + for (auto *f : {axisX_, axisY_, axisZ_, rotStart_, rotIncrement_}) + f->setEnabled(on); +} + +void JFJochViewerSettingsDock::EnableGridFields(bool on) { + for (auto *f : {gridNFast_, gridStepX_, gridStepY_}) + f->setEnabled(on); + gridVertical_->setEnabled(on); + gridSnake_->setEnabled(on); +} + +void JFJochViewerSettingsDock::UpdateGridSummary() { + const auto grid = GridScanFromFields(); + gridSummary_->setText(grid ? QStringLiteral("%1 x %2 points for %3 images") + .arg(grid->GetGridSizeX_step()) + .arg(grid->GetGridSizeY_step()) + .arg(experiment_.GetImageNum()) + : QString()); +} + +std::optional JFJochViewerSettingsDock::GoniometerFromFields() const { + if (!modeRotation_->isChecked()) + return std::nullopt; + // GoniometerAxis throws on an empty name or a zero-length axis, and an exception thrown out of a + // widget signal aborts the viewer - fall back to the defaults for a half-typed entry. + std::string name = axisName_->text().trimmed().toStdString(); + if (name.empty()) + name = DEFAULT_AXIS_NAME; + Coord axis(static_cast(axisX_->value()), static_cast(axisY_->value()), + static_cast(axisZ_->value())); + if (axis.Length() == 0.0f) + axis = Coord(DEFAULT_AXIS); + GoniometerAxis gonio(name, static_cast(rotStart_->value()), + static_cast(rotIncrement_->value()), axis, gonioHelicalStep_); + gonio.ScreeningWedge(gonioScreeningWedge_); + return gonio; +} + +std::optional JFJochViewerSettingsDock::GridScanFromFields() const { + if (!modeGrid_->isChecked()) + return std::nullopt; + // GridScanSettings throws on a zero step, and an exception thrown out of a widget signal aborts + // the viewer - fall back to the default step for a half-typed entry. + float step_x = static_cast(gridStepX_->value()); + float step_y = static_cast(gridStepY_->value()); + if (step_x == 0.0f) + step_x = DEFAULT_GRID_STEP_UM; + if (step_y == 0.0f) + step_y = DEFAULT_GRID_STEP_UM; + GridScanSettings grid(std::llround(gridNFast_->value()), step_x, step_y, + gridSnake_->isChecked(), gridVertical_->isChecked()); + // The rows are however many the images fill, exactly as the reader does when a file carries a + // grid: the file stores n_fast alone. + grid.ImageNum(experiment_.GetImageNum()); + return grid; +} + QWidget *JFJochViewerSettingsDock::BuildReferenceSection() { // A reference dataset for scaling: drives CCref and reference-based scaling. It is // independent of the loaded data (the worker keeps it across file switches); this just lets the @@ -600,7 +788,7 @@ QWidget *JFJochViewerSettingsDock::BuildBraggSection() { } QWidget *JFJochViewerSettingsDock::BuildScalingSection() { - // The partiality model + rot3d combine + scale-fulls are driven by "Process as stills" (indexing + // The partiality model + rot3d combine + scale-fulls are driven by the rotation axis (goniometer // section); the panel keeps the full scaling_ so those fields are preserved here, not reset. auto *section = new CollapsibleSection("Scaling", this); auto *form = new QFormLayout(); @@ -678,6 +866,10 @@ void JFJochViewerSettingsDock::EmitExperiment() { experiment_.BeamY_pxl(static_cast(beamY_->value())); experiment_.PoniRot1_rad(static_cast(rot1_->value() * PI / 180.0)); experiment_.PoniRot2_rad(static_cast(rot2_->value() * PI / 180.0)); + experiment_.PolarizationFactor(polarizationOn_->isChecked() + ? std::optional(static_cast(polarization_->value())) : std::nullopt); + experiment_.Goniometer(GoniometerFromFields()); + experiment_.GridScan(GridScanFromFields()); if (cellKnown_->isChecked()) { experiment_.SetUnitCell(UnitCell{ static_cast(cellA_->value()), static_cast(cellB_->value()), @@ -702,6 +894,44 @@ void JFJochViewerSettingsDock::RefreshGeometryFields() { rot1_->setValue(experiment_.GetPoniRot1_rad() * 180.0 / PI); rot2_->setValue(experiment_.GetPoniRot2_rad() * 180.0 / PI); + const auto polarization = experiment_.GetPolarizationFactor(); + QSignalBlocker blockPolarization(polarizationOn_); + polarizationOn_->setChecked(polarization.has_value()); + polarization_->setEnabled(polarization.has_value()); + if (polarization) + polarization_->setValue(polarization.value()); + + // The mode follows what the experiment describes, and the described one's fields are filled from + // it. The other two keep what they show - the generic defaults, or what the user last entered - + // so switching away and back does not lose an axis or a grid. + const auto gonio = experiment_.GetGoniometer(); + const auto grid = experiment_.GetGridScan(); + QSignalBlocker blockStill(modeStill_), blockRotation(modeRotation_), blockGrid(modeGrid_); + modeRotation_->setChecked(gonio.has_value()); + modeGrid_->setChecked(!gonio.has_value() && grid.has_value()); + modeStill_->setChecked(!gonio.has_value() && !grid.has_value()); + EnableRotationFields(gonio.has_value()); + EnableGridFields(modeGrid_->isChecked()); + if (gonio) { + axisName_->setText(QString::fromStdString(gonio->GetName())); + axisX_->setValue(gonio->GetAxis().x); + axisY_->setValue(gonio->GetAxis().y); + axisZ_->setValue(gonio->GetAxis().z); + rotStart_->setValue(gonio->GetStart_deg()); + rotIncrement_->setValue(gonio->GetIncrement_deg()); + gonioHelicalStep_ = gonio->GetHelicalStep(); + gonioScreeningWedge_ = gonio->GetScreeningWedge(); + } + if (grid) { + gridNFast_->setValue(grid->GetNFast()); + gridStepX_->setValue(grid->GetGridStepX_um()); + gridStepY_->setValue(grid->GetGridStepY_um()); + QSignalBlocker blockVertical(gridVertical_), blockSnake(gridSnake_); + gridVertical_->setChecked(grid->IsVerticalScan()); + gridSnake_->setChecked(grid->IsSnakeScan()); + } + UpdateGridSummary(); + const auto cell = experiment_.GetUnitCell(); QSignalBlocker blockKnown(cellKnown_); cellKnown_->setChecked(cell.has_value()); @@ -739,19 +969,14 @@ void JFJochViewerSettingsDock::datasetLoaded(std::shared_ptrexperiment; have_experiment_ = true; RefreshGeometryFields(); - - // "Process as stills" only applies to a rotation dataset; enable it accordingly and apply the - // resulting indexing/scaling mode so it reaches the worker. - if (stills_) { - stills_->setEnabled(experiment_.GetGoniometer().has_value()); - ApplyProcessingMode(); - } + ApplyProcessingMode(); // the mode just read back decides rotation vs stills } void JFJochViewerSettingsDock::ApplyProcessingMode() { - // Rotation good-path unless the dataset is stills or the user forces "Process as stills". - const bool rotation_data = experiment_.GetGoniometer().has_value(); - const bool rotation_mode = rotation_data && stills_ && !stills_->isChecked(); + // Rotation is the only mode that changes how the data is processed: the rotation good-path + // (rotation indexing + Ewald partiality + rot3d combine + scale-fulls). A grid scan is a raster + // of stills, so it runs exactly as Still does. + const bool rotation_mode = modeRotation_ && modeRotation_->isChecked(); indexing_.RotationIndexing(rotation_mode); // rotation indexing -> rotation scaling/merge downstream scaling_.ScaleFulls(rotation_mode); EmitSpotFinding(); // carries indexing_ (incl. RotationIndexing) to the worker diff --git a/viewer/widgets/JFJochViewerSettingsDock.h b/viewer/widgets/JFJochViewerSettingsDock.h index dca2908a..fb7f658f 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.h +++ b/viewer/widgets/JFJochViewerSettingsDock.h @@ -22,7 +22,9 @@ class QStackedWidget; class QCheckBox; class QComboBox; class QLabel; +class QLineEdit; class QPushButton; +class QRadioButton; class SliderPlusBox; class NumberLineEdit; @@ -81,9 +83,6 @@ private: // Indexing-algorithm combo + the description line under it (kept so the Auto resolution refreshes). QComboBox *algo_ = nullptr; QLabel *algoDesc_ = nullptr; - // "Process as stills" — kept so datasetLoaded can enable it only when the dataset has a goniometer - // (a rotation dataset); it drives indexing/scaling mode via ApplyProcessingMode(). - QCheckBox *stills_ = nullptr; // Geometry fields (shared by both MX and AzInt, since both need the same geometry) NumberLineEdit *energy_ = nullptr; @@ -92,6 +91,32 @@ private: NumberLineEdit *beamY_ = nullptr; NumberLineEdit *rot1_ = nullptr; // detector tilt (PONI rot1), deg NumberLineEdit *rot2_ = nullptr; // detector tilt (PONI rot2), deg + // Polarization factor, also shared: it corrects the azimuthal profile and the Bragg intensities + // alike. Unchecked = no polarization correction (the experiment's factor is unset). + QCheckBox *polarizationOn_ = nullptr; + NumberLineEdit *polarization_ = nullptr; + + // How the sample moves between images — the three cases a dataset can be, exactly as a file + // stores them (a rotation axis, a grid scan, or neither). The choice sets or clears the + // experiment's goniometer / grid scan, and ApplyProcessingMode() turns it into the + // indexing/scaling mode. Each group keeps its values while another mode is selected. + QRadioButton *modeStill_ = nullptr; + QRadioButton *modeRotation_ = nullptr; + QRadioButton *modeGrid_ = nullptr; + QLineEdit *axisName_ = nullptr; + NumberLineEdit *axisX_ = nullptr, *axisY_ = nullptr, *axisZ_ = nullptr; + NumberLineEdit *rotStart_ = nullptr, *rotIncrement_ = nullptr; + // Not editable here, but part of the file's goniometer — kept so rebuilding the axis from the + // fields does not drop them. + std::optional gonioHelicalStep_; + std::optional gonioScreeningWedge_; + // Grid scan. Only the fast-axis point count is a free parameter: the number of slow rows follows + // from the image count (GridScanSettings::ImageNum), which is why there is no second count field + // and why gridSummary_ spells out the grid the settings actually make. + NumberLineEdit *gridNFast_ = nullptr; + NumberLineEdit *gridStepX_ = nullptr, *gridStepY_ = nullptr; + QCheckBox *gridVertical_ = nullptr, *gridSnake_ = nullptr; + QLabel *gridSummary_ = nullptr; // Crystal fields QCheckBox *cellKnown_ = nullptr; NumberLineEdit *cellA_ = nullptr, *cellB_ = nullptr, *cellC_ = nullptr; @@ -111,6 +136,7 @@ private: QWidget *BuildGeometrySection(); QWidget *BuildMXPage(); + QWidget *BuildGoniometerSection(); QWidget *BuildBraggSection(); QWidget *BuildScalingSection(); QWidget *BuildReferenceSection(); @@ -119,8 +145,13 @@ private: void SyncMinPix(); void EmitSpotFinding(); void EmitExperiment(); - void ApplyProcessingMode(); // "Process as stills" -> indexing + scaling rotation/stills mode + void ApplyProcessingMode(); // sample-motion mode -> indexing + scaling rotation/stills mode void UpdateAlgorithmDescription(); void RefreshGeometryFields(); void UpdateSpaceGroupName(); + void EnableRotationFields(bool on); + void EnableGridFields(bool on); + void UpdateGridSummary(); + [[nodiscard]] std::optional GoniometerFromFields() const; + [[nodiscard]] std::optional GridScanFromFields() const; }; diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index a2bb326a..9ab1bf35 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -197,8 +197,9 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec prefixRowLayout->addWidget(prefix); prefixRowLayout->addWidget(browse); - // Rotation-vs-stills mode (rotation indexing + partiality + rot3d) is set in the settings panel via - // "Process as stills"; the dialog only collects run options. Scaling applies to MX full analysis. + // Rotation-vs-stills mode (rotation indexing + partiality + rot3d) is set in the settings panel by + // the goniometer rotation axis; the dialog only collects run options. Scaling applies to MX full + // analysis. auto *scaling = new QCheckBox("Scale && merge", &dlg); scaling->setChecked(true); scaling->setEnabled(!azint && !calibration); @@ -362,7 +363,7 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const config.spot_finding.indexing = false; } if (spec.mode == ProcessMode::FullAnalysis) { - // Rotation indexing follows the panel's "Process as stills" (= the experiment's indexing + // Rotation indexing follows the panel's rotation axis (= the experiment's indexing // setting); a rotation run uses 60 first-pass images to find the lattice. config.rotation_indexing = inputs.experiment.GetIndexingSettings().GetRotationIndexing(); config.two_pass_rotation = true; @@ -396,10 +397,11 @@ void JFJochProcessingJobsWindow::newJob(ProcessMode mode, CalibrationSelection c const ProcessConfig config = buildConfig(spec, inputs); - // The experiment carries the panel's indexing settings — including RotationIndexing set by "Process - // as stills" (needed so IndexAndRefine builds a rotation indexer). On top of that it gets exactly - // the defaults `rugnux` with no options would apply, so the two front ends agree: the analysis - // policy the panel does not expose (the polarization factor) and the rotation scaling defaults. + // The experiment carries the panel's indexing settings — including RotationIndexing set by the + // rotation axis (needed so IndexAndRefine builds a rotation indexer). On top of that it gets + // exactly the defaults `rugnux` with no options would apply, so the two front ends agree on the + // analysis policy — except where the panel states its own, which wins (the polarization factor, + // and the scaling fields below). // // The cell and space group come from the settings panel and nowhere else: with "Unit cell known" // ticked they are indexed and merged with, unticked the panel clears both and the run determines @@ -408,7 +410,9 @@ void JFJochProcessingJobsWindow::newJob(ProcessMode mode, CalibrationSelection c // job's _process.h5 becomes the active snapshot, where a run that ended in P1 shows P1 and can be // cleared, rather than silently pinning every later run to it. DiffractionExperiment experiment = inputs.experiment; + const auto polarization = experiment.GetPolarizationFactor(); ApplyRugnuxExperimentDefaults(experiment); + experiment.PolarizationFactor(polarization); // the panel's setting, not the rugnux default // Calibrating from the run-summed profile needs the profile to be binned in azimuth; the panel's // bin count is an azimuthal-integration setting and defaults to a plain radial profile, which // carries no information about where the ring centre is. Same fallback as the rugnux CLI. @@ -431,8 +435,15 @@ void JFJochProcessingJobsWindow::newJob(ProcessMode mode, CalibrationSelection c } if (action == 2) { // copy command line + // The command line is run against the file, so it has to state what the panel changed. The + // one thing it cannot state is the rotation axis (rugnux has no flag for it) - but switching + // the axis off is `--force-still`, which RugnuxCommandLine emits from the axis the file has, + // not the one the panel cleared. Put the file's back for the command line only. + DiffractionExperiment cmdline_experiment = experiment; + if (!cmdline_experiment.GetGoniometer().has_value()) + cmdline_experiment.Goniometer(inputs.file_goniometer); const QString cmd = QString::fromStdString( - RugnuxCommandLine(config, experiment, inputs.file.toStdString(), + RugnuxCommandLine(config, cmdline_experiment, inputs.file.toStdString(), spec.calibration.name.toStdString())); QApplication::clipboard()->setText(cmd); QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)");