Files
Jungfraujoch/image_analysis/indexing/Indexer.h
T
leonarski_fandClaude Opus 5 89962574ef spindle: the severity no longer rides on the indexing seed or on which indexer is configured
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
2026-09-04 10:59:05 +02:00

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);
};