The score gated on 60 spots, but the seed escalation stops at the leanest seed that indexes - 30 spots on precisely the clean frames a grid scan produces - so the value was absent exactly where beamline automation most needs it, and absence maps to "engage": the protocol would have fired on every good frame, which degenerates the trigger into "always". The floor itself stays where it was calibrated; what changes is what it gates. When no escalation pass could answer, one severity-only pass runs over the full spot list - the row search alone, no reduction, no refinement - purely to produce the number. The same was true of the indexer choice: only the FFT family computes a row shortlist, so a deployment configured with the known-cell indexer - the ordinary online stills path - never produced the score at all. Where the severity-only pass has no row search to run, the severity is read off the rows of the winning lattice instead, which any indexer produces: the lattice's shortest few distinct directions, as many as the FFT shortlist resolves in practice, fed through the same window and scoring with equal magnitudes. The count parity is load-bearing - a worst case over every enumerable lattice direction fires on 100% of harmless mounts of a generic triclinic cell against 74% for this selection at theta_max = 15 deg, and an always-firing trigger decides nothing - while the diad-detection rate stays 1.00 on the monoclinic classes either way, a dropped axis row being recovered by the pair normals exactly as an invisible one is. A frame that neither indexed nor reached the spot floor still reports nothing, which is the honest answer and maps to the recoverable error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
73 lines
3.8 KiB
C++
73 lines
3.8 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <optional>
|
|
|
|
#include "../../common/CrystalLattice.h"
|
|
|
|
#include "../../common/DiffractionExperiment.h"
|
|
#include "../../common/JFJochMessages.h"
|
|
#include "../../common/SpotToSave.h"
|
|
#include "SpindleBlindFraction.h"
|
|
|
|
struct IndexerResult {
|
|
std::vector<CrystalLattice> lattice;
|
|
float indexing_time_s;
|
|
bool executed = false; // If indexing was not performed (due to indexer being not-available) mark it, so indexing result is marked accordingly
|
|
// What the indexer threw, when it threw. A THIRD outcome, not a flavour of the other two: an
|
|
// empty lattice list with no error is a real negative result about the frame - it was examined
|
|
// and no lattice fits it - whereas an error says nothing about the frame at all, the indexer
|
|
// never having got to look, and will recur on the next one.
|
|
std::optional<std::string> error;
|
|
// How much of a single sweep's blind cone this orientation makes unrecoverable, in [0,1].
|
|
// Absent when the frame gives nothing to decide on (no spindle defined, no lattice rows).
|
|
std::optional<float> spindle_blind_fraction;
|
|
std::optional<float> spindle_row_length_A;
|
|
std::optional<float> spindle_miss_angle_deg;
|
|
};
|
|
|
|
class Indexer {
|
|
protected:
|
|
int64_t viable_cell_min_spots = 9;
|
|
float indexing_tolerance = 0.1;
|
|
float dist_tolerance_vs_reference = 0.05;
|
|
bool index_ice_rings = true;
|
|
|
|
DiffractionGeometry geom;
|
|
std::optional<UnitCell> reference_unit_cell;
|
|
// The rotation axis a subsequent sweep would use. Present on a grid scan too, where the axis is
|
|
// defined but stationary - that is exactly the case the spindle severity is for.
|
|
std::optional<Coord> spindle_axis;
|
|
float wavelength_A = 0;
|
|
// Half-angle of the blind double cone a sweep would leave: asin(lambda/2d) at the GEOMETRIC
|
|
// resolution of the setup - the detector corner at the recorded distance and wavelength - not at
|
|
// the still's own spot resolution. A weak, attenuated grid still reaching 3 A would score a row
|
|
// at an 11 deg miss-angle as exactly 0 while the real sweep, at the detector's 1.8 A, loses a
|
|
// fifth of its wider cone. The corner is an upper bound on any sweep collected without moving
|
|
// the detector, it is there on every frame, and it is unbiased where the still's spot list is
|
|
// systematically shallow; a wider cone errs towards reporting loss, which is the cheap error.
|
|
// 0 when the geometry cannot give it.
|
|
float spindle_theta_max_deg = 0;
|
|
|
|
virtual void SetupUnitCell(const std::optional<UnitCell>& cell) = 0;
|
|
virtual std::vector<CrystalLattice> RunInternal(const std::vector<Coord> &coord, size_t nspots) = 0;
|
|
// Set by RunInternal when the implementation computes it; read out by Run.
|
|
virtual std::optional<SpindleSeverity> GetSpindleSeverity() const { return {}; }
|
|
// The row pass alone, over the whole of `coord`, to produce the spindle severity without
|
|
// reducing or refining anything. Implemented by the FFT-family indexers, which own a row
|
|
// search; the others have nothing to answer with and return nothing.
|
|
virtual std::optional<SpindleSeverity> RunSeverityOnly(const std::vector<Coord> &coord) { return {}; }
|
|
public:
|
|
virtual ~Indexer() = default;
|
|
void Setup(const DiffractionExperiment& experiment);
|
|
// severity_only = true runs RunSeverityOnly instead of indexing: no lattice comes back and
|
|
// `executed` stays false, since nothing was an indexing attempt. Used when the seed escalation
|
|
// indexed a frame from fewer spots than the severity's floor - the score must not be absent on
|
|
// precisely the frames that index cleanly.
|
|
IndexerResult Run(const std::vector<Coord> &coord, bool severity_only = false);
|
|
};
|
|
|
|
|