Indexing an unknown cell¶
Every other chapter assumes you know the cell. This one is for when you do not: the specimen is unidentified, or a database hit is a guess, and the lattice has to come out of the pattern itself. Indexing is that step — from the line positions alone, find a lattice whose calculated reflections land where the observed lines are.
Three calls do it, and each answers a smaller question than a reader usually wants answered.
Call |
Takes |
Returns |
|---|---|---|
|
a pattern and an instrument |
|
|
that list, and the pattern |
|
|
a candidate, and the pattern |
|
None of the three returns a singleton, and that is the design.
IndexingResult has no .cell and no .best; ExtinctionScreen has no
.symbol and no .space_group. The only route to one answer is
IndexingResult.best_or_none or ExtinctionScreen.best_or_none, each of which
returns None unless a gate is fully satisfied. On a first run against real
laboratory data, None is the ordinary outcome rather than a failure, and the
reasons are on the answer. The gate is
described below, and what to do about each reason is the
agent protocol’s
§7c rather than this chapter’s.
The physics is Part 2. Indexing carries the quadratic form the search is over, what a peak list can support, the figures of merit in both directions, and the ambiguity that cannot be resolved from positions; Search engines carries the three search algorithms. This chapter is the objects and the calls.
Picking the peaks¶
pick_peaks fits every resolvable line in a pattern and returns a PeakList.
Detection proposes maxima and shoulders, neighbouring lines are fitted together
as a group, and every reported position comes out of a profile fit with its own
esd rather than from a maximum.
import rietx as rx
data = rx.read_pattern("corundum.prn")
ins = rx.Instrument.bragg_brentano(radiation="CuKa")
peaks = rx.pick_peaks(data, ins)
The instrument is used for four things and none of them is refined here:
the primary wavelength and the emission-line set (the peak positions and the
doublet constraint), the U, V, W, X, Y width law (the separation floor and the
width seeds), ProfileTCHZ.shape (so the peak list and the refinement that
follows share one peak shape), and the axial apertures, which are applied and
held.
Argument |
Default |
Does |
|---|---|---|
|
the whole pattern |
restricts detection and fitting to a range |
|
|
proposes shoulder seeds from curvature, not only maxima |
|
|
flags Kβ and tungsten ghosts, which are excluded rather than subtracted |
Abstention is a result here too: a pattern with too few lines comes back as a
list carrying PEAK_LIST_TOO_SHORT, never as an exception.
What a list holds¶
Field |
Holds |
|---|---|
|
every fitted component, in 2θ order |
|
the primary emission line, Å |
|
the range the lines were picked over |
|
|
|
which set of thresholds produced the list |
|
what the picker noticed, as |
PeakList.usable is the list every screen and every engine actually runs on:
the lines left after ghosts, failed fits, caller exclusions and inseparable
components are dropped. The dropped lines stay in PeakList.peaks so a report
can say why a line went, which a filtered-at-source list cannot.
Five accessors return the usable lines as arrays, which is the form the engines
and the figures of merit want: PeakList.two_theta, PeakList.two_theta_esd,
PeakList.q, PeakList.q_esd and PeakList.intensity.
On the bundled corundum pattern (7251 points, 5–150° 2θ, Cu Kα) picking takes about 0.2 s and returns 62 components, 54 of them usable.
What one line holds¶
Each entry in PeakList.peaks is an ObservedPeak: one fitted component, with
the group it was fitted in.
Field |
Holds |
|---|---|
|
fitted position of the Kα1 component, ° |
|
its esd, carrying the √max(χ²_red, 1) inflation |
|
1/d², Å⁻², derived from the position and the list’s wavelength |
|
σ(Q), by the exact derivative of that relation |
|
integrated area of the primary line |
|
its esd |
|
the group’s combined Γ, ° |
|
the group’s pseudo-Voigt mixing, 0 = Gaussian |
|
which fitted group this component came from |
|
how many components were fitted simultaneously with it |
|
reduced χ² of that group’s fit |
|
what the fit or the screens noticed about this line |
|
|
ObservedPeak.d is the d-spacing, and ObservedPeak.usable is this line’s half
of PeakList.usable.
Two of those fields are the group’s rather than the line’s. Within a group —
a fraction of a degree — the widths are not separately identifiable, so fwhm
and eta are shared, and pretending otherwise is what lets a doublet fit
absorb an unresolved neighbour.
Q rather than d or 2θ is the working coordinate throughout, because Q is linear
in the reciprocal metric ((10.1)), which is what makes a cell fit a
linear problem. The list validates that every ObservedPeak.q agrees with its
own 2θ, so a peak built by hand with a stale Q raises here rather than
mis-indexing a pattern later.
The flags¶
ObservedPeak.flags is a closed vocabulary. Five of the twelve take a line out
of PeakList.usable; the rest are evidence a consumer weighs.
Flag |
Means |
Usable? |
|---|---|---|
|
a Kβ contamination line |
no |
|
a tungsten L contamination line |
no |
|
the caller removed it |
no |
|
the group solve did not converge, so the position is the seed |
no |
|
a component the fitter believes as a shape and disbelieves as a line |
no |
|
σ was supplied rather than fitted |
yes |
|
it never separated from its neighbour by half a FWHM |
yes |
|
the fit pushed to its position bound: detection seeded it in the wrong place |
yes |
|
one-sided asymmetry beyond what the model absorbs |
yes |
|
it stands where the background was extrapolated, not measured |
yes |
|
it sits on a much stronger group-mate’s axial-divergence tail side |
yes |
|
it sits at a stronger group-mate’s predicted Kα2 maximum |
yes |
A contamination line is flagged and excluded, never subtracted: Rachinger stripping redistributes the noise and biases what is left. The last three flags report evidence rather than refusing a line, because a real reflection can coincide with an extrapolated background, with a stronger line’s axial tail or with a predicted Kα2 position, and one pattern cannot tell which it is. §7b of the protocol says which ones are usually worth excluding before a search, and what that has been measured to cost.
A list you were handed¶
PeakList.from_positions builds a list from bare positions — a publication, a
database entry, another program’s output.
import numpy as np
import rietx as rx
# LaB6, the first six lines at Cu Kα1, as they would be quoted in a paper
peaks = rx.PeakList.from_positions(
np.array([21.358, 30.385, 37.442, 43.507, 48.964, 53.996]),
wavelength=1.540596)
assert peaks.source == "positions"
assert len(peaks.usable()) == 6
assert all("sigma_assumed" in p.flags for p in peaks.peaks)
assert abs(peaks.peaks[0].d - 4.1566) < 1e-3
Every line is flagged sigma_assumed and PeakList.source reads positions,
because an assumed σ is unmeasured: it must never be quoted as a precision,
and a gate that weights lines by 1/σ² is being handed a constant rather than
information. Intensities default to equal weight, which is what a
position-only list actually says. Pass two_theta_esd= if you know better,
and intensity= when the source quotes relative intensities — the search is
driven by the strongest lines, so intensities change which lines it uses.
Is this list fit to index?¶
rietx.indexing.assess_peak_list answers that, and index_pattern calls it
before spending any budget. The answer is a DataQualityReport.
Field |
Holds |
|---|---|
|
can this list be searched at all |
|
why not, when it cannot |
|
lines that count, of lines picked |
|
inherited from the list: |
|
the range the lines span |
|
position precision, ° |
|
median σ(Q)/Q — a resolving power ((10.3)) |
|
median σ(Q) over the mean spacing between neighbouring Q |
|
usable lines ÷ metric degrees of freedom, per system |
|
the systems this list can support a search in |
|
figures of merit undefined on this list, name → reason |
|
Smith’s volume bound, ų, per system |
|
the systematic-shift screen, or |
|
which thresholds produced the verdict |
|
what the assessment noticed |
“Enough lines” is a per-system question, not a number. The metric has one
free parameter in cubic and six in triclinic ((10.4)), so the same
list is enormously over-determined for one system and barely determined for
another. DataQualityReport.lines_per_dof carries the ratio and
DataQualityReport.systems_supported carries the verdict. On the corundum list
above, 54 usable lines read 54.0 per degree of freedom for cubic and 9.0 for
triclinic, and every system is supported.
Searchable and scorable are different questions, and conflating them once
refused a pattern this package indexes perfectly. Below twenty usable lines the
classical figures are undefined — de Wolff’s M₂₀ and Smith & Snyder’s F₂₀ are
defined on twenty lines ((10.8), (10.9)) — so the search still
runs over the supported systems, ranks on the reduced panel, and names each
missing figure with its reason in DataQualityReport.fom_undefined. What that
costs is the grade: a short list can never reach high.
DataQualityReport.volume_envelope is Smith’s (1977) bound on the cell volume
from the d-spacing of the N-th line ((10.7)), and it is per system
because the bound differs by up to 96× across them: a cubic F lattice shows far
fewer distinct lines than a primitive triclinic one of the same volume. It is
the default max_volume for a search.
The systematic shift, and the window it opens¶
A 2θ shift is measurable before the cell is, and it has to be, because a
candidate’s calculated positions are matched against uncorrected observed
lines. DataQualityReport.shift is a ShiftScreen.
Field |
Holds |
|---|---|
|
lines the screen used |
|
each template fitted alone, as a |
|
the template that fits best, or |
|
whether that name means anything |
|
how far apart the templates are over the angles sampled |
|
largest disagreement between the competitive templates’ corrections, ° |
|
residual scatter the winning template leaves, ° |
|
what a search window must span, ° |
|
|
|
the harmonic-pair evidence, when that is where the number came from |
allowance_deg and sigma_sys_deg are not the same number and the
difference decides whether a search finds anything. The scatter is what the
winning template leaves; the allowance has to span the shift’s own
amplitude, because nothing has corrected the observed positions yet. On
certified SRM 660c the two read 0.0078° and 0.037°, a factor 4.3, and declaring
the smaller one makes the search find nothing. Declare
SearchSpecSpec.shift_allowance_deg from the allowance, never from the
scatter.
The other direction is worse. A window wider than the shift manufactures a
confident wrong answer: at 0.060° on the same certified pattern the search
returns a cell 293 000 ppm from the certificate, graded high. This is why the
allowance is derived rather than guessed, and why an assumed one caps the
grade.
ShiftScreen.best names a cause — a zero-point error, a specimen
displacement, transparency — and ShiftScreen.separable says whether the data
can tell them apart, which over a limited angular range it frequently cannot.
The magnitude survives that; the cause does not. Each ShiftTemplateFit carries
ShiftTemplateFit.name, ShiftTemplateFit.coefficient (the template’s
amplitude in ° 2θ), ShiftTemplateFit.stderr, ShiftTemplateFit.r2 and
ShiftTemplateFit.residual_ss — and the ratio is computed on the residual sum
of squares rather than on R², because every template scores R² ≈ 0.99 against a
clean trend.
With no reference positions the shift is still recoverable, from harmonic
reflection pairs ((10.5), (10.6)) — pairs of lines whose
sines are in an integer ratio, which for any lattice is one equation in the
shift and none in the cell. index_pattern runs that screen by default
(shift_from_pairs=True); assess_peak_list does not, so a report you build
yourself carries ShiftScreen.source == "unavailable" unless you ask. Its
evidence is a ReflectionPairScreen, reported in full because the method’s
failure mode is accidental agreement and the only way to judge that is to see
how much agreement a structureless list of the same size produces.
Field |
Holds |
|---|---|
|
line pairs whose sine ratio rounded to an integer |
|
of those, the ones admitted inside the window |
|
pairs inside the densest window — the statistic itself |
|
the same statistic on structureless replicates |
|
the standardised gap, and the empirical p |
|
how many replicates, and the seed that drew them |
|
scatter of the clustered pairs about the reported amplitude |
|
templates the pair evidence rules out |
|
why no shift was reported, when none was |
The method may refute a cause and may not choose between the two that stay
collinear. Read ReflectionPairScreen.refuted_templates as “not this one”,
never as “therefore that one”.
Running the search¶
result = rx.index_pattern(peaks, data=data, instrument=ins)
Argument |
Default |
Does |
|---|---|---|
|
— |
the |
|
|
the pattern. Supplying it is what turns whole-profile validation on |
|
|
needed with |
|
|
the search bounds, as the frozen |
|
|
the whole-run ceiling; |
|
every registered engine |
which searches to run |
|
|
a |
|
|
recover the shift allowance from harmonic pairs |
|
|
run the Le Bail validation when a pattern is available |
|
|
how many candidates get the expensive per-candidate checks |
|
|
restrict the range the validation fits use |
|
|
the streaming event ladder, as everywhere else |
|
|
a |
Two of those defaults are worth keeping rather than tuning. Passing data is
what makes validation possible, and without it every candidate caps at
medium. Leaving engines alone is what lets high mean anything at all, for
the reason in the next section.
Three engines, and why the default is all of them¶
Confidence in this package is engines agreeing, not any statistic. The ones
registered today fail in different ways, which is the whole point: a wide search
domain defeats one, a poisoned base line defeats another, a bad starting basin
defeats the third. Restricting engines narrows what the answer is able to say,
and adding an engine raises the bar rather than diluting it, because high
means every engine that ran found the same lattice.
Ask which are here rather than assuming, since the set is a registry:
import rietx as rx
caps = rx.capabilities()
engines = {engine.name: engine.description for engine in caps.indexing_engines}
assert engines and all(engines.values())
Each row is an EngineCapability, and both of its fields —
EngineCapability.name and EngineCapability.description — are quoted from
that live registry, so a client’s engine checkboxes and the agent schema cannot
name different sets. Calling rietx from a program has the rest of capabilities().
Only one of the three carries an exhaustiveness claim: when the branch-and-bound
engine finishes a system, “no cell here” is evidence. That claim survives only
where IndexingResult.search_complete is true for the system, which is what
makes that field worth reading before concluding anything from a silence.
Presets, budgets, and the three states of a system¶
An exhaustive search over seven crystal systems has no natural stopping point,
so runs are bounded, and the bound is reported rather than hidden. The presets
come from Capabilities.search_presets, one SearchPresetCapability each.
Field |
Holds |
|---|---|
|
the preset’s key: |
|
its display name |
|
what it bounds, and what the worst case is |
|
the chooser’s sentence |
|
whether |
quick is the default: every engine, every requested system, and a whole-run
ceiling covering search, probe and validation. Nothing is narrowed — no
engine dropped, no system dropped, no search box shrunk. What a binding ceiling
cuts is the trailing low-symmetry systems, which is the documented cost of
running cheapest-first, and it says so with INDEX_BUDGET_EXHAUSTED. full
removes the whole-run ceiling and leaves only the per-slice budget, which is
the pre-1.0 behaviour and the right choice for a rerun when a quick run
reported that the answer might live in a system it never reached.
There are two budgets and they are per different things.
SearchSpecSpec.budget_seconds is per (engine × crystal system) slice;
SearchSpecSpec.total_budget_seconds is the whole run. Units run
system-major — every engine finishes one system before any engine starts the
next — so a binding deadline sacrifices trailing systems for every engine
equally, and a completed system holds every engine’s answer, which is what the
agreement gate needs. rietx.indexing.engines.estimate_ceiling is the
arithmetic for choosing a value before starting.
After a run, three states are distinguishable, and the distinction is the answer’s honesty:
the system is in
IndexingResult.systems_searchedwithIndexingResult.search_completetrue — searched to exhaustion;in
systems_searchedwithsearch_completefalse — truncated, so a negative result there means nothing;absent from
systems_searched— never reached at all.
Measured on the bundled corundum pattern with everything left at its defaults: 120.2 s wall clock, all three engines, five of the seven systems entered, of which cubic and hexagonal completed, and monoclinic and triclinic never started. The answer says all of that.
Declaring what you know¶
SearchSpecSpec is the full control surface. It is flat and complete rather
than a handful of convenience knobs, because the engines’ agreement only means
something if they were given identical bounds.
Field |
Default |
Does |
|---|---|---|
|
all seven |
which crystal systems to search, in decreasing symmetry |
|
every centring a system admits |
per-system Bravais centrings; an empty list is refused |
|
2 Å, 25 Å |
the principal d-spacing range — raising the top costs exponentially |
|
15 ų |
volume floor |
|
Smith’s envelope |
volume ceiling, taken verbatim when declared |
|
2 |
search lines a cell may leave unexplained and still be accepted |
|
20 |
observed lines the search is driven by — the strongest N |
|
3 |
matching window in units of each line’s own σ |
|
0 |
a systematic allowance you measured |
|
|
re-fit a surviving candidate with this shift column |
|
30 s |
per (engine × system) |
|
the preset’s |
the whole run |
|
|
which preset governs the ceiling |
|
12 |
how many candidates the answer reports |
|
0 |
the stochastic engine’s seed, and part of its answer |
|
|
cells from a structural analogue, to try first |
|
|
space-group symbols from that analogue |
SearchSpecSpec.to_spec converts it to the SearchSpec dataclass
index_pattern takes. The two mirror each other field for field, held by a
test, because the same controls are the agent request’s, the project
document’s and the GUI form’s:
import rietx as rx
from rietx.schemas.indexing import SearchSpecSpec
controls = SearchSpecSpec(systems=["trigonal", "hexagonal"],
centrings={"trigonal": ["R"]},
max_volume=600.0, budget_seconds=60.0)
spec = controls.to_spec()
assert spec.systems == ("trigonal", "hexagonal")
assert spec.max_volume == 600.0
Several of those fields refuse rather than narrow in silence. An unknown crystal system, centring, shift template or preset raises with the live vocabulary in the message; an empty centring list raises rather than skipping the system, because omitting the key is how a system keeps its full set; and a prior cell with a non-positive axis or an angle outside (0, 180)° raises too.
SearchSpecSpec.n_search_lines is the one to leave alone. Raising it is
neither free nor safe: a cell must index all but n_unindexed of those
lines, an absolute budget, so every extra foreign line admitted can refute the
true cell rather than merely rank it lower.
A prior steers, never gates. A declared prior_cells entry puts its crystal
system at the front of the queue, seeds the stochastic engine’s starting basin
with its metric, and is checked against the lines the engines’ own way. No
system is dropped and no range is changed, so a wrong prior costs time rather
than truth, and INDEX_PRIOR_USED records what was assumed. Declare one
whenever you have a database hit or an isostructural analogue; §7d of the
protocol has the worked example.
IndexingControls is the same thing one level up: the settings an indexing
run carries that are not the data.
Field |
Holds |
|---|---|
|
the |
|
which engines to run |
|
whether to run whole-profile validation |
|
how many candidates get the expensive checks |
It is what a project document persists (Files and projects) and what the agent request carries (Calling rietx from a program), so a run can be repeated from a stored setting rather than from a call site.
The answer¶
IndexingResult is a ranked list of hypotheses with the evidence behind each,
plus what the search covered.
Field |
Holds |
|---|---|
|
the ranked |
|
engines that actually ran — the denominator of the agreement gate |
|
systems any engine covered |
|
per system: did every engine that searched it exhaust its domain |
|
per-engine counters, prefixed with the engine’s name |
|
do the panel’s members put different candidates first |
|
the |
|
was a pattern supplied, so validation could run at all |
|
usable lines the answer is about |
|
the primary wavelength, Å |
|
which preset governed the ceiling, or |
|
version, timestamp and the spec, as everywhere else |
|
which gates and vocabularies produced the grades |
|
run-level findings |
Result-level and candidate-level statements are kept apart on purpose.
IndexingResult.diagnostics says things about the run — a truncated budget,
systems not covered, an assumed allowance — while a statement about one cell
lives in that candidate’s own CellCandidate.diagnostics and
CellCandidate.confidence_caveats.
One candidate¶
Field |
Holds |
|---|---|
|
a, b, c (Å) and α, β, γ (°), with esds |
|
crystal system, and Bravais centring letter |
|
the absence-free group of the lattice: holohedry plus centring |
|
cell volume, ų |
|
the six quadratic-form parameters actually fitted ((10.2)) |
|
lines this cell explains, of lines offered |
|
reduced χ² of the metric fit |
|
the shift column re-fitted with the cell, if one was |
|
the figure-of-merit panel, as |
|
which engines produced this lattice |
|
geometrically indistinguishable partners |
|
the two independent opinions on the lattice symmetry |
|
the whole-profile test, or |
|
|
|
every reason it is not |
|
findings about this candidate |
CellCandidate.fom_value reads one panel member by name and returns None
rather than raising, because which members exist depends on what the list
could support.
CellCandidate.lattice_group is the absence-free group, never a plausible
space group, and the distinction is load-bearing: a group carrying reflection
conditions would hide exactly the reflections whose absence is not yet
established, and hiding them is how an oversized cell passes.
The figure-of-merit panel¶
The panel ranks; it does not score. A margin is comparable within one member and not across them, so the members vote rather than being summed, and each carries what it is blind to.
Field |
Holds |
|---|---|
|
which member this is; |
|
its value |
|
lines it used, and lines the lattice allows |
|
the matching window it was computed at, in units of each line’s σ |
|
mean |Δ| of the matched lines, in the member’s own units; −1 when nothing matched, which is not zero |
|
what this member cannot see |
FigureOfMerit.blind_spot is a field rather than documentation because every
published figure of merit has a failure mode, and a consumer that reads a value
without it is one step from a confident wrong answer. M₂₀’s own text says that
it counts lattice-possible lines, so it is blind to space-group extinctions,
and that its mean discrepancy is trimmed to match what the search was allowed
to leave unindexed.
On a full-length list the panel runs to seven members: de Wolff’s M₂₀ and Smith & Snyder’s F_N ((10.8), (10.9)), three coverage fractions, and Oishi-Tomiyasu’s two reversed figures. Coverage is scored in both directions, and that is what the reversed members are for: share-of-observed alone puts a supercell above the truth, since a supercell indexes every observed line, while the reversed direction asks how much of what the cell predicts was actually seen.
The lattice symmetry, and its ambiguities¶
CellCandidate.bravais is a BravaisOpinion — two independent readings of the
same reduced cell, kept apart on purpose, because gemmi’s tolerance is an
obliquity in degrees and spglib’s is a distance in Å, so a disagreement between
them is information about the cell rather than a bug in either.
Field |
Holds |
|---|---|
|
the symmetry that survives the whole tolerance sweep |
|
the highest symmetry any tolerance reported |
|
each method’s own answer |
|
the symmetry appears only at a loose tolerance |
|
the two methods do not agree |
|
the Niggli-reduced cell the symbols refer to |
A powder pattern carries only the length of a reciprocal vector, so distinct
lattices can produce the same line positions ((10.10)). Those are
reported, never resolved, as AmbiguityPartner entries.
Field |
Holds |
|---|---|
|
the partner lattice |
|
the integer basis transformation to it |
|
|det| of that transformation |
|
the hkl that would break the tie |
|
where a line would have to appear, or be absent, to do so |
The discriminating reflections are what make an ambiguity actionable rather than merely honest: they say which part of the pattern to measure again, or further.
The whole-profile test¶
The figure-of-merit panel sees at most twenty lines. A Le Bail fit against the
whole pattern sees three things the panel cannot: lines beyond the panel,
reflections predicted where there is no intensity, and impurity content. The
middle one is the classic doubled-cell false positive, and it is why
validation is mandatory rather than optional. CellCandidate.lebail is that
test, as a LeBailValidation.
Field |
Holds |
|---|---|
|
the fit’s agreement |
|
the absence-free lattice group it used |
|
reflections in the fitted range |
|
reflections the lattice predicts where the pattern has none |
|
observed lines with no calculated reflection nearby |
|
where each of those is, ° |
|
the underlying refinement’s status; |
|
stages the validation plan ran |
|
findings from the fit |
The fit holds the cell: the candidate is the hypothesis under test, and letting it walk would validate a different cell from the one reported. What it frees is the background, exactly one peak-position parameter — chosen from the candidate’s own shift template — and then the widths. It is single-phase, which is a measured constraint rather than a simplification: Le Bail partitioning has nothing to arbitrate two phases claiming the same channel.
LeBailValidation.rwp is deliberately not a panel member. It costs a
refinement, so it is computed for a shortlist rather than for every candidate,
and reading it as a rank would reintroduce the blind spot it exists to close,
since a bigger cell fits better. Read predicted_but_absent as “this cell
predicts lines the pattern lacks”, never as “this cell is too big”: it counts
against the lattice group, so a space-group extinction — a glide plane, a
screw axis — refutes a perfectly correct cell, and only the extinction screen
below separates the two.
The confidence gate¶
CellCandidate.confidence is high, medium or low. The top level is
agreement between independent engines, not a threshold on any statistic,
and every reason a candidate falls short is a member of a closed vocabulary in
CellCandidate.confidence_caveats. Six of the twelve refute the candidate —
each is positive evidence against the cell, or evidence that the data cannot
choose — and drop it to low. The other six cap it at medium.
Caveat |
Means |
Effect |
|---|---|---|
|
a distinct lattice fits the positions as well |
refutes |
|
the panel’s members put different candidates first |
refutes |
|
the Le Bail fit found reflections where the pattern has no intensity |
refutes |
|
the cell explains less than 90 % of the usable lines |
refutes |
|
the volume is outside what the data can support |
refutes |
|
the Le Bail fit raised or diverged |
refutes |
|
fewer than every engine that ran found this lattice |
caps |
|
no pattern was supplied, so nothing tested it against the profile |
caps |
|
a budget expired, so a negative result elsewhere means nothing |
caps |
|
the matching window was widened by an assumed systematic |
caps |
|
the list is too short for the classical figures |
caps |
|
the lattice symmetry appears only at a loose tolerance, or the methods disagree |
caps |
not_validated and validation_failed are separate on purpose: the absence of
a test and a failed test are different statements, and only the second is
evidence about the cell.
IndexingResult.best_or_none returns a candidate only when exactly one is
high and it has no ambiguity partners. Everything else — nothing found, two
cells that both explain the pattern, an unvalidated search, an assumed
tolerance — returns None.
Never take candidates[0] because it is ranked first. The ranking orders
the hypotheses and the gate judges them; they are different questions. The
order leads with corroboration — the candidates at least two engines found —
and ranks the panel within that, which is closer to the gate’s reading than a
panel ranking alone, and still not it.
On the corundum run above, the certified trigonal R lattice comes back at
rank 1, found by two of the three engines, with a Le Bail fit that converged —
and grades low on five caveats, so best_or_none returns None. Two of the
five carry the reading. predicted_but_absent counts 12 reflections, which is
what a space-group extinction looks like from a lattice group that does not
know about it, and the extinction screen below is what separates that from an
oversized cell. search_incomplete is there because the ceiling cut the
trailing systems. Neither says the cell is wrong; they say what has not been
established, which is the whole difference between this answer and a confident
one.
The evidence view¶
The gate exists for unattended use, where a machine that cannot weigh evidence
must never be handed one cell. A consumer that can weigh evidence wants the
inputs to that judgement instead, and IndexingResult.evidence is those inputs
in one place, as an IndexingEvidence. Everything in it is a projection of
fields the result already carries, computed on each call, so the two can never
disagree.
Field |
Holds |
|---|---|
|
one |
|
what the search covered |
|
what the peak list supports at all |
|
lines the answer is about |
|
the panel members that ranked every candidate |
|
members that could not be computed, each with its reason |
|
whether a pattern was supplied at all |
Field |
Holds |
|---|---|
|
position in |
|
the lattice |
|
the grade |
|
every caveat with its kind |
|
which engines found it |
|
its coverage |
|
the panel members that ranked it, name → value |
|
whether a Le Bail fit ran on this candidate |
|
that fit’s outcome |
|
its two detector counts |
|
how many partners it has |
CaveatEvidence is the piece confidence_caveats withholds:
CaveatEvidence.name is the caveat and CaveatEvidence.kind is refuting or
capping. That split lives in a package constant a JSON consumer cannot see,
and an agent told predicted_but_absent and not_validated in the same breath
needs to know the first argues against the cell while the second only says a
question was never asked.
For agents
Read IndexingResult.evidence rather than the gate alone, and read
CandidateEvidence.lebail_rwp beside the two detector counts rather than
scoring on it. The three together are what let a reader notice that a detector
has failed: on one measured pair, the correct cell reads
predicted_but_absent 2 and its wrong rival reads 0 — backwards — while Rwp
reads 0.25 against 0.79. A reasoner given both can see that; the gate, reading
one number, cannot. This is an argument for surfacing Rwp, never for ranking on
it.
The same shape arrives over JSON: agent.refine_json’s index task answers in
an indexing arm with an evidence companion beside it, and the arm carries no
cell key either. Calling rietx from a program has the envelope.
rietx.viz.plot_indexing draws the ranked candidates as tick rows against the
pattern, with the Le Bail panel, from the result alone. The visual check is
part of the answer rather than documentation of it: a wrong cell that scores
well usually looks wrong immediately.
From a candidate to a phase¶
A candidate is a lattice, and a refinement needs a Structure.
rietx.indexing.structure_from_candidate builds the single-phase model that
the Le Bail validation itself uses:
from rietx.indexing import structure_from_candidate
candidate = result.best_or_none() # or one you chose after reading the evidence
phase = structure_from_candidate(candidate)
lebail = rx.refine(data, phase, ins, mode="lebail", plan="profile_only")
Two things about it are load-bearing. The dummy atom is mandatory — a phase
cannot have an empty atom list, and a candidate cell has no structure yet, which
is the entire point — and in Le Bail mode every atom path is force-fixed, so it
contributes nothing and shows as mode_fixed rather than editable in
The parameter table‘s listing. And space_group defaults to the absence-free
lattice group, for the reason CellCandidate.lattice_group exists: a
plausible-looking space group would hide the very reflections whose absence has
not been established.
The reverse direction closes too. When a refinement’s Layer 2 emits
reindex_or_recheck_cell — peak offsets beyond the linearisation radius across
most of the misfitting regions — that action has something to call: pick peaks
and index the same pattern. The fit report has the action, and §7d of the
protocol has the loop.
The extinction symbol¶
Once a lattice is established, the next question is which reflections are
systematically absent. determine_extinction_symbol answers it — and answers
it as a class, never as a space group.
screen = rx.determine_extinction_symbol(data, candidate, ins,
two_theta_limits=(15.0, 90.0))
klass = screen.best_or_none()
if klass is not None:
print(klass.symbol, klass.space_groups)
Argument |
Default |
Does |
|---|---|---|
|
— |
the pattern, the lattice under test, and the instrument |
|
|
a peak list, used to seed the shared profile fit |
|
the whole pattern |
the range classes are enumerated and judged over |
|
3 |
the matching window, in units of each line’s σ |
|
|
cap the number of classes fitted |
|
|
cooperative cancellation |
The pipeline is one shared profile fit of the absence-free lattice group, a reference fit of the absence-free class under the same protocol, then one Le Bail fit per class scored by ΔBIC and by Hamilton’s ratio test against that reference, and finally a direct absence test: intensity at a position the class forbids refutes it, with the hkl named. Every class is fitted with the shared instrument frozen, so no class can compensate a missing reflection with a wider peak.
The candidate’s CellCandidate.system is taken as given rather than
re-screened, because when the Bravais screen reported an ambiguity that field
is the conservative reading, and enumerating classes in a higher symmetry would
offer classes the lattice may not have.
Field |
Holds |
|---|---|
|
the ranked |
|
the absence-free group every class is compared against |
|
the lattice screened |
|
the primary wavelength, Å |
|
the range judged over — part of the answer |
|
classes enumerated, classes actually fitted |
|
the absence-free reference fit |
|
the shared profile fit every class inherits |
|
channels in the fitted range |
|
the screen’s own status |
|
which thresholds produced the ranking |
|
findings, including the one that names the groups a class cannot separate |
Two classes differing only outside ExtinctionScreen.two_theta_range are one
class here, which is why the range is reported as part of the answer rather
than as a setting.
Field |
Holds |
|---|---|
|
the IT-style extinction symbol, derived from the members |
|
the H-M symbol whose reflections were generated |
|
every space group in the class, in IT number order |
|
the derived reflection conditions, for a human to check |
|
whether the derivation named every absence |
|
distinct lines this class predicts in range |
|
lattice lines it forbids |
|
of those, the ones the data can actually check |
|
testable forbidden positions carrying intensity — the refutation |
|
which reflections those are, and where |
|
its own Le Bail fit |
|
BIC against the absence-free reference; negative favours this class |
|
Hamilton’s test in the same direction |
|
was this class actually fitted |
|
refuted, and why |
|
findings about this class |
n_absent and n_testable answer different questions and the gap between them
is usually large. A forbidden position that falls outside the fitted range, or
that coincides with a line the class still allows, is not an observation — an
absence hiding under a neighbour is not an absence you saw.
Refutation is one-sided by construction. A class asserts absences, so
intensity where it forbids one contradicts it; a class claiming too few
absences asserts nothing the data can falsify, and is outranked rather than
refuted. ExtinctionScreen.best_or_none therefore returns a class only when it
was fitted, is not refuted, rests on at least one absence the data could test
(or is the absence-free class itself, whose claim is that there is nothing to
see), is separated from the next surviving class by a decisive ΔBIC margin, and
no unrefuted class was left unfitted — a max_classes cap or a cancelled
run leaves an unasked question, which must not read as a clean answer.
On the GSAS-II fluorapatite tutorial pattern the screen enumerates seven
classes over 15–90° in about two seconds and returns P 63 - -, whose members
are P 63, P 63/m and P 63 2 2 — with one condition, 00l: l = 2n, and
ΔBIC −21.8 against the absence-free class. That is a complete answer rather
than a hedge: the mirror and the two-folds that separate those three produce no
absences at all, so no counting time distinguishes them, and
EXTINCTION_GROUPS_NOT_SEPARABLE says so. Choosing inside a class is
chemistry, not diffraction — any member can be handed to
structure_from_candidate for the fit that follows, because they predict the
same reflections at the same positions.
Read ExtinctionScreen.profile_rwp before believing a refutation. Every
class is fitted with that shared instrument frozen, so a poor shared fit is a
poor screen, and a badly modelled profile puts intensity exactly where a class
says there should be none. Measured on the corundum pattern above: over the
whole 5–150° range with the round-robin instrument’s declared widths, the
shared fit reaches Rwp 0.287 and five forbidden positions read as occupied; run
over 20–90° with the widths seeded from the peak list, it reaches 0.149 and two
do. So give the screen a range and a width law its profile fit can actually
match, and read ExtinctionScreen.profile_rwp to check that it did.
And refutation outranks ΔBIC, one-sidedly. On that same better-fitted
screen the specimen’s certified R - c - class sits at ΔBIC −251 against the
absence-free class — strong evidence for it — and is still refuted, on those
two positions, the first named as (2, 0, 5) at 56.919°. That is the rule
working as designed rather than an override to be tuned away, but it makes the
named reflections worth checking: a single flagged position can be an impurity
line rather than a violated absence, and the same specimen’s indexing run
reported 49 observed lines its own top candidate did not explain. §7e of the
protocol says how to make that check.
Further reading¶
What to do about each answer — the agent protocol, §7b (peak-picking diagnostics), §7c (the answer’s own diagnostics), §7d (the closed loop from an unknown pattern to a refinement), §7e (extinction) and §7f (the gate against the evidence). This chapter is the surface; that is the judgement, and neither restates the other.
The physics — Indexing and Search engines.
What comes next — Running a refinement for the fit the cell feeds, and The fit report for the report that says whether it holds up.