Peaks, indexing and a Le Bail check

Written by Claude Code, Anthropic’s coding agent, for the rietx project.

When the phase is unknown there is no CIF to start from. The cell has to come from the peak positions alone: find the peaks, then search for a cell that puts a reflection under each one. That search is called indexing. We index a synthetic pattern whose answer we know, read why rietx declines to name a single cell, and check the best candidate with a Le Bail fit.

You need rietx 1.7 or later, which the next cell installs. Notebook 01 introduces Le Bail fitting.

The data is synthetic aragonite, CaCO₃, made from its published structure: Dal Negro and Ungaretti (1971), American Mineralogist 56, 768, as entry 9000229 of the Crystallography Open Database (public domain). The displacement parameters are the isotropic equivalents of that entry’s anisotropic ones.

Runtime is under a minute on a laptop, most of it the indexing search.

%pip install rietx

A synthetic pattern

import tempfile
from pathlib import Path

import numpy as np

import rietx as rx
from rietx.indexing import SearchSpec, structure_from_candidate

work = Path(tempfile.mkdtemp())
cif = work / "aragonite.cif"
cif.write_text(
    "data_aragonite\n_space_group_name_H-M_alt 'P m c n'\n"
    "_cell_length_a 4.9616\n_cell_length_b 7.9705\n_cell_length_c 5.7394\n"
    "_cell_angle_alpha 90\n_cell_angle_beta 90\n_cell_angle_gamma 90\n"
    "loop_\n_atom_site_label\n_atom_site_type_symbol\n_atom_site_fract_x\n"
    "_atom_site_fract_y\n_atom_site_fract_z\n_atom_site_B_iso_or_equiv\n"
    "Ca1 Ca 0.25 0.4151 0.2403 0.67\n"
    "C1  C  0.25 0.7627 0.0850 0.80\n"
    "O1  O  0.25 0.9231 0.0952 1.25\n"
    "O2  O  0.4729 0.6801 0.0870 1.19\n",
    encoding="utf-8")
truth = rx.Structure.from_cif(cif)

true_instrument = rx.Instrument.bragg_brentano(radiation="CuKa")
true_instrument.profile.w.value = 0.004
true_instrument.profile.x.value = 0.05
true_instrument.background = rx.BackgroundChebyshev.with_terms(1)
true_instrument.background.coefficients[0].value = 200.0

two_theta = np.arange(10, 80, 0.02)
y = rx.Refinement(truth, true_instrument).predict(two_theta) - 200.0
truth.phases[0].scale.value *= 20000 / y.max()   # strongest peak about 20 000 counts
y = rx.Refinement(truth, true_instrument).predict(two_theta)
data = rx.PatternData(two_theta=two_theta,
                      intensity=np.random.default_rng(0).poisson(y).astype(float))
data.plot()

From here on we pretend not to know the structure. The true cell is a = 4.9616, b = 7.9705, c = 5.7394 Å, and the indexer should find it in some order of axes.

Find the peaks

pick_peaks finds every resolvable peak and fits each one for its position, with an esd. It needs an instrument for the wavelength and a first guess at the peak width. We give it a default copper instrument, as you would for an unknown sample.

lab = rx.Instrument.bragg_brentano(radiation="CuKa")
peaks = rx.pick_peaks(data, lab)
peaks
PeakList: 105 peaks, 59 usable, 10–79.98° 2θ, λ = 1.54059 Å, source fitted
#two_thetatwo_theta_esdintensityfwhmoriginflags
021.07390.002431.40.101fitted
122.29460.00569.60.084fitted
226.22210.00032175.40.096fittedasymmetry_unmodelled
326.61920.010618.10.095fittedasymmetry_unmodelled
427.22000.00041241.70.095fittedasymmetry_unmodelled
531.13940.001692.80.110fitted
632.75010.0010170.80.096fittedasymmetry_unmodelled
732.76000.0019168.70.097fittedposition_at_bound, asymmetry_unmodelled, duplicate_line
833.15230.00031066.90.096fittedasymmetry_unmodelled
933.15240.00061065.80.097fittedasymmetry_unmodelled, duplicate_line
1036.14880.0006946.20.125fitted
1137.25780.0025324.20.098fittedduplicate_line
1237.26510.0011298.20.100fittedasymmetry_unmodelled
1337.89870.0335953.50.117fittedduplicate_line
1437.90640.0043849.30.100fittedasymmetry_unmodelled, duplicate_line
1537.90710.0004870.70.097fitted
1637.90740.0005867.30.098fittedasymmetry_unmodelled, duplicate_line
1738.39490.0069554.10.117fittedduplicate_line
1838.40240.0005620.70.097fitted
1938.40330.0007644.10.098fittedasymmetry_unmodelled, duplicate_line
2038.57840.0098378.00.117fittedposition_at_bound, duplicate_line
2138.63120.0007401.90.097fitted
2241.19490.0008270.70.094fittedasymmetry_unmodelled
2342.90700.0008426.40.100fitted
2445.48330.002171.20.098fitted
2545.50000.003870.00.099fittedposition_at_bound, asymmetry_unmodelled, duplicate_line
2645.85680.00151462.20.101fittedasymmetry_unmodelled, duplicate_line
2745.85880.00031531.20.098fitted
2845.85890.00051528.60.099fittedasymmetry_unmodelled, duplicate_line
2946.01340.013739.20.101fittedasymmetry_unmodelled, kalpha2_residual
3046.32510.11701.30.101fittedposition_at_bound, no_intensity, asymmetry_unmodelled
3148.30960.0007639.60.100fittedfit_failed
3248.30970.0007632.90.100fitted
3348.46000.0011419.80.100fittedposition_at_bound
3448.4639405.0436130.10.100fittedfit_failed, unresolved_shoulder, no_intensity, position_unmeasured, asymmetry_unmodelled, kalpha2_residual
3548.4639186.4488282.60.100fittedfit_failed, unresolved_shoulder, no_intensity, position_unmeasured, asymmetry_unmodelled
3649.88390.006231.20.099fittedasymmetry_unmodelled, duplicate_line
3749.88490.006032.70.100fittedasymmetry_unmodelled
3850.23970.0007621.60.100fittedduplicate_line
3950.23980.0007628.00.099fittedasymmetry_unmodelled
4051.91290.003857.90.101fittedasymmetry_unmodelled
4152.49080.0013657.30.101fittedasymmetry_unmodelled, duplicate_line
4252.49100.0019671.80.098fittedasymmetry_unmodelled, duplicate_line
4352.49110.0004662.80.100fitted
4452.91060.0012143.90.100fitted
4552.91110.0064141.60.098fittedasymmetry_unmodelled, duplicate_line
4652.99180.0297490.70.240fittedfit_failed, asymmetry_unmodelled
4753.05430.0007298.40.100fitted
4853.05490.0042307.20.098fittedasymmetry_unmodelled, duplicate_line
4953.37130.01689.20.110fittedasymmetry_unmodelled
5053.95710.002545.80.110fittedasymmetry_unmodelled
5156.14940.001755.10.102fittedduplicate_line
5256.14940.001555.90.101fitted
5356.20200.012519.70.083fitted
5456.29730.005627.30.083fitted
5556.43870.004018.10.101fitted
5656.43910.004318.40.102fittedduplicate_line
5756.79560.020823.90.102fittedduplicate_line
5856.80860.003339.30.083fittedasymmetry_unmodelled, duplicate_line
5956.80890.002135.60.101fitted
6059.24470.0017152.50.101fittedasymmetry_unmodelled
6159.60040.01587.70.107fitted
6260.20300.002935.90.107fitted
6361.81980.0012120.70.107fitted
6462.91010.001958.10.106fittedasymmetry_unmodelled
6562.91020.002058.10.107fittedasymmetry_unmodelled, duplicate_line
6663.33380.0011149.60.106fittedasymmetry_unmodelled, duplicate_line
6763.33400.0010148.80.107fitted
6865.03810.00918.20.090fitted
6966.11430.002477.90.097fitted
7066.14860.0042124.40.154fittedduplicate_line
7166.21260.003161.70.097fitted
7266.53570.006472.10.154fittedasymmetry_unmodelled, duplicate_line
7366.54440.002072.20.097fitted
7468.64270.001574.80.098fitted
7568.64300.001573.80.100fittedasymmetry_unmodelled, duplicate_line
7669.10010.0012104.40.099fittedduplicate_line
7769.10060.0012102.80.098fitted
7869.10110.0013102.40.100fittedasymmetry_unmodelled, duplicate_line
7969.58740.002639.50.099fitted
8069.58750.003039.80.109fittedasymmetry_unmodelled, duplicate_line
8169.58850.002740.80.098fittedduplicate_line
8270.08190.002451.00.109fittedasymmetry_unmodelled, duplicate_line
8370.08300.002354.30.099fitted
8470.28700.016512.40.114fitted
8570.87470.005035.20.114fitted
8672.28820.003915.70.058fittedasymmetry_unmodelled
8772.29000.005511.80.088fittedduplicate_line
8872.49570.01265.10.088fitted
8975.29030.0010148.50.108fitted
9076.66010.003163.80.106fittedasymmetry_unmodelled
9176.66030.003463.50.106fittedasymmetry_unmodelled, duplicate_line
9276.66040.003363.50.106fittedasymmetry_unmodelled, duplicate_line
9376.78020.0021115.90.106fitted
9476.78020.0022116.00.106fittedasymmetry_unmodelled, duplicate_line
9576.78030.0022116.20.106fittedasymmetry_unmodelled, duplicate_line
9677.09970.0011191.00.106fitted
9777.09980.0012192.10.106fittedasymmetry_unmodelled, duplicate_line
9877.10070.0013193.80.106fittedasymmetry_unmodelled, duplicate_line
9978.03600.0021123.70.115fittedduplicate_line
10078.03760.0015122.00.116fitted
10178.69490.005220.80.115fitted
10278.73850.047364.80.116fittedposition_at_bound, no_intensity
10379.33430.016335.70.115fitted
10479.40330.0012208.50.123fittedasymmetry_unmodelled
  [ info  ] PEAK_UNRESOLVED_SHOULDER: 2 line(s) never separated from a neighbour by half a FWHM, so their positions are correlated with it and their σ says so
           at: 48.4639°, 48.4639°
  [ info  ] PEAK_KALPHA2_RESIDUAL: 2 weak component(s) sit at a strong group-mate's predicted Kα2 maximum at a few per cent of its area — the residual of a *modelled* doublet, re-created by a re-seed pass after detection dropped the alias candidate (WP-1043)
           at: 46.0134°, 48.4639°
  [warning] PEAK_ASYMMETRY_UNMODELLED: 49 line(s) leave a significant odd residual after fitting, i.e. an asymmetry the declared profile does not carry; an unmodelled one-sided aberration biases the centroid in one direction, which σ cannot see
           at: 26.2221°, 26.6192°, 27.2200°, 32.7501°, 32.7600°, 33.1523°, 33.1524°, 37.2651°, 37.9064°, 37.9074°, 38.4033°, 41.1949°, 45.5000°, 45.8568°, 45.8589°, 46.0134°, 46.3251°, 48.4639°, 48.4639°, 49.8839°, 49.8849°, 50.2398°, 51.9129°, 52.4908°, 52.4910°, 52.9111°, 52.9918°, 53.0549°, 53.3713°, 53.9571°, 56.8086°, 59.2447°, 62.9101°, 62.9102°, 63.3338°, 66.5357°, 68.6430°, 69.1011°, 69.5875°, 70.0819°, 72.2882°, 76.6601°, 76.6603°, 76.6604°, 76.7802°, 76.7803°, 77.0998°, 77.1007°, 79.4033°
  [warning] PEAK_WIDTH_LAW_MISMATCH: measured FWHM 0.1565° against 0.0322° from the instrument's U,V,W,X,Y — a factor of 4.9; seeds and windows were scaled by 4.86 to match the data
           at: median of the 12 most prominent lines
  [ info  ] PEAK_KALPHA2_ALIAS: 26 candidate(s) dropped as the Kα2 maximum of a stronger line rather than lines of their own; the doublet is fitted as a constrained pair, so each reported position is a Kα1 position
           at: 33.2400°, 38.0200°, 38.5000°, 41.3000°, 43.0200°, 45.9800°, 48.6000°, 50.3800°, 52.6200°, 53.2000°, 56.3000°, 59.4000°, 60.3600°, 62.0000°, 63.0800°, 63.5200°, 66.7200°, 68.8400°, 69.2800°, 69.7600°, 70.2800°, 71.0800°, 75.5000°, 77.3200°, 78.2600°, 79.6400°
  [ info  ] PEAK_SHOULDER_SEEDED: 1 curvature seed(s) offered for components with no local maximum; survival was decided by ΔBIC, not by detection
           at: 51 groups

Each row is one peak: its position, the esd of that position, its area and its width. flags records anything the fit noticed. A flagged peak can still be usable, and the count line says how many are.

The diagnostics under the table explain the list. PEAK_WIDTH_LAW_MISMATCH says the peaks are about five times wider than the default instrument predicts, so the picker scaled its windows to match. PEAK_KALPHA2_ALIAS counts candidates it dropped as the Kα2 partner of a stronger line: each reported position is a Kα1 position.

Measure lines you name

fit_peaks fits exactly the positions you give it, and nothing else. Use it to measure lines you have chosen: a few reflections for a size analysis, or one line as a quick check.

chosen = rx.fit_peaks(data, lab, [26.22, 27.22, 33.13])
chosen
PeakList: 3 peaks, 3 usable, 10–79.98° 2θ, λ = 1.54059 Å, source fitted
#two_thetatwo_theta_esdintensityfwhmoriginflags
026.22210.00032175.40.096manualasymmetry_unmodelled
127.21990.00061246.20.094manualasymmetry_unmodelled, unnamed_neighbour
233.15140.00311113.50.092manualasymmetry_unmodelled, unnamed_neighbour
  [warning] PEAK_ASYMMETRY_UNMODELLED: 3 line(s) leave a significant odd residual after fitting, i.e. an asymmetry the declared profile does not carry; an unmodelled one-sided aberration biases the centroid in one direction, which σ cannot see
           at: 26.2221°, 27.2199°, 33.1514°
  [warning] PEAK_WIDTH_LAW_MISMATCH: measured FWHM 0.1565° against 0.0322° from the instrument's U,V,W,X,Y — a factor of 4.9; seeds and windows were scaled by 4.86 to match the data
           at: median of the 12 most prominent lines
  [ info  ] PEAK_KALPHA2_ALIAS: 26 candidate(s) dropped as the Kα2 maximum of a stronger line rather than lines of their own; the doublet is fitted as a constrained pair, so each reported position is a Kα1 position
           at: 33.2400°, 38.0200°, 38.5000°, 41.3000°, 43.0200°, 45.9800°, 48.6000°, 50.3800°, 52.6200°, 53.2000°, 56.3000°, 59.4000°, 60.3600°, 62.0000°, 63.0800°, 63.5200°, 66.7200°, 68.8400°, 69.2800°, 69.7600°, 70.2800°, 71.0800°, 75.5000°, 77.3200°, 78.2600°, 79.6400°
  [ info  ] PEAK_SHOULDER_SEEDED: 1 curvature seed(s) offered for components with no local maximum; survival was decided by ΔBIC, not by detection
           at: 51 groups

Rebuilding a whole list from edited positions in code works less well: a real line you leave out still has intensity, and its neighbours absorb it.

The same work in the GUI

Editing a peak list is easier by hand, and rietx has a graphical mode for it. Run rietx gui in a terminal, open or create a project with your pattern, and choose the Peaks tab. The plot then becomes an editing surface:

Gesture

Does

click empty space

add a line, measured by fit_peaks

drag a marker

move it

shift-click

exclude a line from indexing

right-click

remove it

The same tab runs the indexing search, draws a candidate’s predicted lines over the pattern, and adopts a candidate as the starting point of a Le Bail fit. Every gesture also has a typed equivalent in the panel. The GUI guide covers the tab in full.

Index

index_pattern searches for cells that index the usable peaks. A search over every crystal system can take minutes, so we narrow it. We search only orthorhombic cells, with a volume between 200 and 250 ų and no axis longer than 8.5 Å. Those limits come from knowing the answer. With a real unknown, start from the formula and a wide window, and let the search run longer.

spec = SearchSpec(systems=("orthorhombic",), min_volume=200, max_volume=250, max_d_axis=8.5)
indexing = rx.index_pattern(peaks, data=data, instrument=lab, spec=spec)
indexing
engines: dichotomy, svd, trial_error   systems: orthorhombic   lines: 59   validated: yes
search INCOMPLETE in: orthorhombic — a negative result there is not evidence
#confidencelatticea b c (Å)α β γ (°)V (ų)indexedchi2_redLe Bail Rwpconfidence_caveats
1loworthorhombic P4.96150 5.73930 7.9703890.000 90.000 90.000226.9650/590.220.2570predicted_but_absent, indexed_fraction_low, search_incomplete
NO CELL: the result abstains — see the diagnostics below.
  [ info  ] PEAK_POSITION_PRECISION: median σ(2θ) 0.0019°, worst 0.0168°; median σ(Q)/Q = 5.76e-05 and σ(Q) is 0.002 of the mean line spacing
           - 2θ 10.00-79.98°
  [warning] INDEX_SEARCH_INCOMPLETE: the svd search did not finish orthorhombic within 30 s per system, so finding no cell there is not evidence that none exists
           - orthorhombic
  [ info  ] INDEX_SHIFT_FROM_PAIRS: a systematic shift of 0.0052° 2θ was measured from 8 harmonic reflection pairs agreeing to 0.0049° (of 17 admitted, z = 6.6 against 200 structureless replicates, p = 0.005) — no reference positions were needed; the cause is not named: no template was refuted
           - allowance = 0.0052° 2θ (measured)
           - best template = cos_theta
  [warning] INDEX_ABSTAINED: no cell reached the confidence gate; the best candidate (orthorhombic P, V = 227.0 ų) is low because of: predicted_but_absent, indexed_fraction_low, search_incomplete
           - searched: orthorhombic
  [ info  ] INDEX_SYSTEMS_NOT_COVERED: 6 crystal system(s) were not searched, so this result says nothing about them
           - cubic
           - hexagonal
           - trigonal
           - tetragonal
           - monoclinic
           - triclinic
  [ info  ] INDEX_CELL_SYSTEMATIC_UNQUANTIFIED: a Bragg-Brentano cell carries ≈ ±85 ppm that no esd reports: sweeping the goniometer radius over 180-320 mm moves Rwp by only 0.029 points, so the data cannot identify it, while specimen displacement absorbs the change 4.6× and the rest lands on the lattice parameters
           - R = 217.5 mm (declared, not measured by the fit)
  [warning] INDEX_PREDICTED_BUT_ABSENT (candidate orthorhombic P V=227.0): 4 of 105 reflections this lattice predicts have no net intensity above the fitted background — the oversized-cell signature, and the one M20 cannot see (its N_poss denominator penalises it only weakly, Oishi-Tomiyasu 2013)
  [warning] INDEX_IMPURITY_LINES (candidate orthorhombic P V=227.0): 96 observed peak(s) have no calculated reflection nearby after a whole-pattern Le Bail fit of this cell

The top candidate is the true cell, with its axes in a different order. All three search engines found it.

Yet the result abstains: best_or_none() returns None, and the verdict line says NO CELL. rietx names a single cell only when exactly one candidate reaches high confidence, and this one is low for the reasons in its row.

  • predicted_but_absent: the indexer checks a candidate in the lattice’s highest-symmetry space group, which has no systematic absences. Aragonite’s real space group, Pmcn, has glide planes that remove some reflections. Those reflections are predicted but absent.

  • indexed_fraction_low: some usable lines are not indexed. A few of them are weak false lines the picker found beside strong peaks.

The candidate’s INDEX_IMPURITY_LINES warning comes from the indexer’s own quick Le Bail fit, whose Rwp in the table is poor. A poor fit leaves many peaks unmatched, so that count is not evidence of a second phase. Our own Le Bail fit below settles it.

print(indexing.best_or_none())
candidate = indexing.candidates[0]
print(candidate.cell, candidate.confidence_caveats)
None
(4.961504341308777, 5.7392991240231845, 7.9703799717669055, 90.0, 90.0, 90.0) ['predicted_but_absent', 'indexed_fraction_low', 'search_incomplete']

Check the candidate with Le Bail

structure_from_candidate turns a candidate into a structure for Le Bail fitting. It uses the lattice’s highest-symmetry space group (here P m m m), so the fit tests the lattice and nothing else. As in notebook 01, we start the background at the data’s floor and run the plan twice.

structure = structure_from_candidate(candidate)
print(structure.phases[0].space_group)

instrument = rx.Instrument.bragg_brentano(radiation="CuKa")
instrument.background = rx.BackgroundChebyshev.with_terms(4)
instrument.background.coefficients[0].value = float(np.percentile(data.intensity, 5))
ref = rx.Refinement(structure, instrument)
passes = [ref.fit(data, mode="lebail", plan="profile_only") for _ in range(2)]
best = min(passes, key=lambda r: r.statistics.rwp)
print(f"Rwp by pass: {[round(r.statistics.rwp, 4) for r in passes]}")
for edge in "abc":
    p = best.parameter(f"phases.0.cell.{edge}")
    print(f"{edge} = {p.value:.5f} +/- {p.stderr:.5f} Å")
P m m m
Rwp by pass: [0.0707, 0.0607]
a = 4.96156 +/- 0.00008 Å
b = 5.73953 +/- 0.00009 Å
c = 7.97054 +/- 0.00013 Å
best.plot()

The fit puts a reflection under every peak and leaves only small residuals, so the lattice is right. The refined edges match the true cell, in the candidate’s order of axes.

Some ticks stand over no peak, such as those near 11° and 15.5°. They are reflections P m m m allows and aragonite’s glide planes remove: the indexer’s predicted-but-absent reflections.

What this does not settle is the space group. That is a question about which reflections are absent, and it comes after the cell is refined.

Checking an agent’s work

When an agent reports a cell it indexed, check these. The rules are in the rietx agent skill (SKILL.md §6, and references/diagnostics-indexing.md).

  • Did best_or_none() return a cell? If not, the agent chose one. Ask why, and read the chosen candidate’s confidence_caveats (rule 19).

  • What was searched? A search narrowed by system, volume or axis length says nothing about the cells outside it. INDEX_SYSTEMS_NOT_COVERED and INDEX_SEARCH_INCOMPLETE say what was left out.

  • Was the cell checked with a whole-pattern fit? A Le Bail fit of the candidate should leave no peak unexplained.

  • Is the space group a separate claim? A cell from indexing comes with the lattice’s highest-symmetry group. The real space group needs its own evidence.