Patterns, structures and instruments

Three objects describe a refinement: the measured pattern, the crystal structure, and the instrument that recorded it. Files and projects says where each one comes from on disk. This chapter says what is inside them, so you can build one by hand, check what a reader handed you, or change one field without guessing what else moves.

Every number a fit can refine is a Parameter, and every Parameter has a dot-path. The parameter table is the table those paths address and how to read and edit a row of it; How a refinement works groups the paths by what they do to the pattern and explains which groups fight each other. This chapter is the objects those paths are built from.

The schemas reject what they cannot interpret. Every one of them forbids unknown fields, so a misspelled keyword is an error at construction rather than a setting that silently did nothing, and bounds are checked when the object is built rather than when the fit starts.

Every refinable number is a Parameter

Field

Type

Default

Meaning

Parameter.value

float

required

the current value, in Parameter.unit

Parameter.vary

bool

False

whether the least-squares problem is free in it

Parameter.min

float

-inf

inclusive lower bound

Parameter.max

float

inf

inclusive upper bound

Parameter.unit

str or None

None

a label, not a conversion: nothing rescales by it

Parameter.stderr

float or None

None

the esd, written by the fit

Parameter.transform

"identity", "softplus", "exp", "logit"

"identity"

internal reparameterisation, (9.2)

Parameter.expr

None

None

reserved, not implemented, and must stay None

value must lie within the bounds and min must not exceed max, both checked on construction. Infinite bounds survive a JSON round-trip as "Infinity", which is why an unbounded parameter is a legal thing to save.

from rietx import Parameter

a = Parameter(value=4.15689, min=4.0, max=4.3, vary=True, unit="A")
width = Parameter.positive(1e-3, vary=True, unit="deg^2")
assert width.transform == "softplus" and width.min == 0.0

Parameter.positive is the constructor for a quantity with no physical meaning below zero: a peak width, a scale, an absorption coefficient. It sets min=0.0 and the softplus transform, which keeps the optimiser away from the hard bound instead of letting it stall against it.

Warning

Softplus does not promise a strictly positive value. The internal variable maps to log(1 + exp(u)), which underflows to exactly 0.0 for u below about −745, so a min=0.0 softplus parameter can reach zero. That is harmless wherever zero is the off state, which is nearly everywhere in this package: a zero width is no broadening, a zero extinction coefficient is no extinction. It is a bug wherever the physics divides by the value, and such a parameter carries a real floor instead. PreferredOrientation.r is the one that does.

stderr is the one field a fit writes back. It is None on a parameter that was never free, and also on a free one whose esd could not be estimated; Reading the numbers explains what an absent esd means and how the fitted values are read back.

The pattern

PatternData is the measurement: two arrays of the same length, and what is known about their uncertainty.

Field

Type

Default

Meaning

PatternData.two_theta

list[float]

required

2θ in degrees, strictly increasing

PatternData.intensity

list[float]

required

measured intensity, same length

PatternData.sigma

list[float] or None

None

per-point esd from the file; None selects the Poisson fallback

PatternData.excluded_regions

list[tuple[float, float]]

[]

2θ intervals to leave out of the fit

PatternData.metadata

dict[str, str]

{}

what the reader found in the file header

Strictly increasing is enforced, not sorted for you. A file stored high to low is reversed by the reader, which reports that it did; a file whose 2θ column is not monotone at all is a refusal, because sorting it, concatenating it and splitting it are three different measurements. Files and projects has that rule and the four other places a reader may repair a file.

Five methods read the pattern out. PatternData.tt and PatternData.y are float64 numpy views of the two columns. PatternData.sig is the one that matters:

from rietx import PatternData

data = PatternData(two_theta=[10.0, 10.02, 10.04], intensity=[120.0, 480.0, 0.0])
assert list(data.sig().round(4)) == [10.9545, 21.9089, 1.0]

σ is a lookup, never a re-derivation. sig() returns the file’s sigma where the file had one and √max(y, 1) where it did not, and every weighted quantity in the package divides by the result: the objective (8.1), every renderer’s difference curve, both GUI windows. The Poisson fallback is correct for raw counts and wrong by √t for anything already divided by a counting time, which is why a reader that cannot establish the intensity scale withholds σ rather than inventing it. Reported esds of zero are floored, since a zero esd is an infinite weight on one channel.

PatternData.in_range_mask is the boolean mask that excluded_regions implies, and PatternData.crop returns a new pattern over a 2θ interval. Cropping and excluding are different acts: a cropped pattern has fewer points, an excluded region leaves the points in place and out of the residual. Prefer excluding, so the count that a statistic quotes still describes the file. A project records its excluded regions in its own document rather than in the pattern, for a reason Files and projects gives.

Never subtract an estimated background from intensity. Hold it additively with BackgroundFixedPlusChebyshev or co-refine it under the smoothness penalty of BackgroundPSpline. Subtracting changes the counting statistics that sigma describes while leaving sigma alone.

The structure

Structure is a list of phases and nothing else. Structure.phases carries at least one; Structure.from_cif reads one out of a CIF and Structure.to_cif writes it back.

Phase is one crystalline phase. Its first four fields describe the crystal and the rest describe what this specimen did to the peaks.

Field

Type

Default

Meaning

Phase.name

str

required

a label, and the key an export writes

Phase.space_group

str

required

Hermann-Mauguin symbol or a number as a string, resolved by gemmi

Phase.cell

Cell

required

lengths and angles

Phase.atoms

list[Atom]

required

the asymmetric unit, at least one

Phase.scale

Parameter

1.0, fixed, softplus

this phase’s contribution to the total intensity

Phase.lor_size

Parameter

0.0 deg, softplus

Lorentzian size broadening, 1/cos θ

Phase.lor_strain

Parameter

0.0 deg, softplus

Lorentzian strain broadening, tan θ

Phase.gauss_size

Parameter

0.0 deg², softplus

Gaussian size broadening, 1/cos²θ

Phase.gauss_strain

Parameter

0.0 deg², softplus

Gaussian strain broadening, tan²θ

Phase.extinction

Parameter

0.0, fixed, softplus

secondary extinction, (5.11); 0 is E ≡ 1 exactly

Phase.preferred_orientation

PreferredOrientation or None

None

single-axis March-Dollase, (5.13)

Phase.microstrain

StephensStrain or None

None

anisotropic strain, width per hkl, (6.2)

Phase.particle_radius_um

float or None

None

Brindley microabsorption input, (5.16); a plain float, never refined

Phase.restraints

list

[]

soft observational restraints, (9.5)

The four broadening terms are the sample half of the instrument ⊕ sample split. Gaussian variances add under convolution and Lorentzian widths add, which is why the two pairs carry different units: deg² for the Gaussian pair and deg for the Lorentzian one. Each term stacks on the instrument term with the same θ-dependence, so one pattern cannot separate the two halves, and no shipped plan frees both. mccusker_default frees the instrument widths and none of these four; lab_sample_refine frees these four and none of the instrument’s.

particle_radius_um cannot be obtained from the pattern at all, which is why it is a plain float rather than a Parameter. Profile broadening measures the coherent domain, which is smaller than and unrelated to the particle whose absorption path Brindley’s correction integrates over, and conflating the two is a standing error. Supply it from a micrograph or a particle-size measurement, or leave it None.

Cell holds six parameters and applies no symmetry itself.

Field

Type

Default

Meaning

Cell.a, Cell.b, Cell.c

Parameter

required

axis lengths in Å

Cell.alpha, Cell.beta, Cell.gamma

Parameter

required

angles in degrees

Store all six and let the space group decide which are independent. ParameterTable ties them from the space-group setting, which is not the same as the crystal system: an R lattice on rhombohedral axes needs a = b = c with the angles free, and monoclinic has three unique-axis choices. A symmetry-fixed angle that disagrees with its symmetry is refused rather than snapped, because the table has no channel in which to report a correction. The reader has one, so a small deviation is repaired at read and recorded; see Files and projects.

Cell.cubic builds all six from one length, and Cell.lengths_angles returns the six values as a tuple.

Atom is one site in the asymmetric unit.

Field

Type

Default

Meaning

Atom.label

str

required

the site label, as a CIF spells it

Atom.species

str

required

the scattering species: "La", "B", "Fe3+"

Atom.x, Atom.y, Atom.z

Parameter

required

fractional coordinates

Atom.occ

Parameter

1.0, in [0, 1.5]

site occupancy

Atom.biso

Parameter

0.5 Ų, in [0, 25]

isotropic displacement, B = 8π²·U

Atom.aniso

AnisoU or None

None

anisotropic displacement, CIF U^ij, (4.4)

species is validated when the model compiles rather than when the object is built, so an unknown symbol fails with a crystallographic message instead of a schema error. Coordinates do not refine as x, y and z: the table wires one degree of freedom per direction the site symmetry allows and ties the three coordinates to those, so a fully fixed special position contributes no free entries at all and vary=True on such a coordinate raises. The parameter table has the paths and what a tied row looks like.

An atom has one displacement model. Set aniso and biso becomes an inert record of the starting estimate; asking to refine both raises rather than leaving a dead parameter in the vector.

from rietx import Atom, Cell, Parameter, Phase, Structure

lab6 = Structure(phases=[Phase(
    name="LaB6",
    space_group="P m -3 m",
    cell=Cell.cubic(4.15689, vary=True),
    atoms=[
        Atom(label="La", species="La", x=Parameter(value=0.0),
             y=Parameter(value=0.0), z=Parameter(value=0.0)),
        Atom(label="B", species="B", x=Parameter(value=0.19964),
             y=Parameter(value=0.5), z=Parameter(value=0.5)),
    ],
)])
assert lab6.phases[0].cell.lengths_angles()[:3] == (4.15689, 4.15689, 4.15689)

The optional blocks

Three fields take a block that is absent by default. Each is opt-in for the same reason: declaring one changes which parameters a plan will free, and reading a file must not do that silently.

AnisoU stores the six CIF U^ij components in Ų, the numbers a _atom_site_aniso_U_ij loop carries. AnisoU.u11, AnisoU.u22 and AnisoU.u33 are required and AnisoU.u12, AnisoU.u13, AnisoU.u23 default to zero. AnisoU.isotropic builds the tensor equivalent to a given U_iso for a cell, which is not U_iso on the diagonal unless the reciprocal axes are orthogonal; AnisoU.from_values takes the six numbers in order and AnisoU.values returns them. Components refine through the site-symmetry patterns rather than one at a time, so min and max on a component are inert and a tensor outside the allowed subspace raises.

StephensStrain is anisotropic strain broadening. Fifteen coefficients, each named for the monomial h^H k^K l^L it multiplies, in units of 10⁻¹² Å⁻⁴:

H+K+L = 4, by shape

Fields

one index

StephensStrain.s400, StephensStrain.s040, StephensStrain.s004

three and one

StephensStrain.s310, StephensStrain.s301, StephensStrain.s130, StephensStrain.s031, StephensStrain.s103, StephensStrain.s013

two and two

StephensStrain.s220, StephensStrain.s202, StephensStrain.s022

two and one and one

StephensStrain.s211, StephensStrain.s121, StephensStrain.s112

They multiply the literal monomials, where some other codes fold the symmetry multiplicities in, so a coefficient copied from another program has to be matched by the width it produces rather than by its name. Symmetry decides which are independent, and the space group’s own operators derive that set. StephensStrain.isotropic seeds the block from one strain in ppm of ΔM/M and a cell, and it is the only legal starting point, because the width goes as a square root whose slope is unbounded at zero. StephensStrain.from_values and StephensStrain.values are the tuple interface. Declaring the block locks Phase.lor_strain, whose column is identically the isotropic direction of the subspace.

PreferredOrientation is the March-Dollase correction: PreferredOrientation.axis is a fixed integer hkl and PreferredOrientation.r is the one refinable number, with r = 1 the identity.

The instrument

Instrument is everything about the measurement except the sample.

Field

Type

Default

Meaning

Instrument.source

Source

required

radiation: wavelengths, line weights, polarisation

Instrument.geometry

Geometry

capillary, no aberrations

how the specimen sits in the beam

Instrument.zero_shift

Parameter

0.0 deg, in [−0.5, 0.5]

a constant 2θ offset, the one position error every geometry has

Instrument.profile

ProfileTCHZ

see below

the instrumental width function

Instrument.background

one of three

BackgroundChebyshev

the pedestal under the peaks

Three constructors build a plausible instrument, and the difference between them is which aberrations exist at all rather than which are switched on.

Constructor

Geometry

Radiation

Declares

Instrument.debye_scherrer

capillary

one wavelength you pass

polarization=0.99, optional capillary radius and µR

Instrument.bragg_brentano

flat plate, reflection

an anode name, Kα1 + Kα2

goniometer radius 217.5 mm, ka2_ratio=0.5, optional monochromator angle

Instrument.flat_plate_transmission

flat plate, transmission

an anode name, Kα1 by default

optional µt and thickness

from rietx import Instrument

synchrotron = Instrument.debye_scherrer(wavelength=0.4139090)
lab = Instrument.bragg_brentano(radiation="CuKa", monochromator_two_theta=26.6)
assert [line.wavelength for line in lab.source.lines] == [1.5405929, 1.5444274]
assert round(lab.source.polarization.value, 4) == 0.5557

That 26.6° is a Cu number rather than a property of graphite. The same crystal sits near 12.1° at Mo Kα, where K is 0.511 rather than 0.556, so compute it for the anode in use instead of copying the example.

Ask capabilities() for the anode names rather than trusting a list in prose.

The source

Field

Type

Default

Meaning

Source.lines

list[EmissionLine]

required

one entry per emission line, at least one

Source.polarization

Parameter

0.5

the fraction K of (5.1); 0.5 is an unpolarised beam

Source.dispersion

Dispersion or None

on

anomalous scattering, (4.6)

Source.kind

"xray_cw"

"xray_cw"

constant-wavelength X-rays, the only kind today

Source.primary_wavelength is the first line’s wavelength, which is the one every d-spacing is quoted against.

EmissionLine.wavelength is a plain float in Å and EmissionLine.weight is a refinable intensity relative to line 0. Line 0’s weight is structurally locked at 1, since it is degenerate with the phase scales. Each line diffracts at its own Bragg angle, so a doublet’s splitting grows with tan θ ((2.3)) and is never a fixed 2θ offset.

Dispersion is on by default. Dispersion.table names the tabulation and Dispersion.overrides takes measured f′, f″ pairs per element, which is what you need near an absorption edge, where the table is wrong in principle rather than merely coarse. Setting dispersion=None declines the correction and reproduces the pre-v1.0 numbers exactly, and the fit says so with a diagnostic. It is the only correction in the package that needs no information a caller lacks, since the species and the wavelength are enough.

The geometry

Geometry.kind selects one of "debye_scherrer", "bragg_brentano" and "flat_plate_transmission", and it decides which of the other fields are meaningful. A field that belongs to another geometry is refused rather than ignored.

Every geometry:

Field

Type

Default

Meaning

Geometry.kind

literal

"debye_scherrer"

which of the three geometries

Geometry.goniometer_radius_mm

float or None

None

R in mm; required for bragg_brentano, and what (2.6) divides by

Geometry.axial_sl, Geometry.axial_hl

Parameter

0.0

Finger-Cox-Jephcoat axial divergence: sample and detector half-lengths over R

Geometry.packing_fraction

float

0.6

solid fraction of the specimen, an absorption estimator input

A capillary, kind="debye_scherrer":

Field

Type

Default

Meaning

Geometry.capillary_offset_along_beam

Parameter

0.0 mm

the sin 2θ half of (2.6), positive downstream

Geometry.capillary_offset_across_beam

Parameter

0.0 mm

the cos 2θ half, positive toward increasing 2θ

Geometry.mu_r

float or None

None

µ·R of the packed specimen, (5.4); 0.0 is off exactly

Geometry.capillary_radius_mm

float or None

None

bore radius in mm, an estimator input for µR

A flat plate, kind="bragg_brentano" or kind="flat_plate_transmission":

Field

Type

Default

Meaning

Geometry.sample_displacement

Parameter

0.0 mm

specimen surface off the goniometer axis, cos θ, (2.4)

Geometry.sample_transparency

Parameter

0.0

penetration into the specimen, (2.5)

Geometry.mu_t

float or None

None

µ·t of the specimen, (5.7) and (5.8)

Geometry.thickness_mm

float or None

None

specimen thickness in mm, an estimator input for µt

Geometry.surface_roughness

RoughnessSuortti, RoughnessPitschke or None

None

low-angle intensity loss, (5.9) or (5.10); reflection only

Two things here are easy to get wrong.

The absorption coefficients are plain floats, not parameters, and their off states disagree. A capillary is off at µR = 0, and a flat plate in reflection is off at µt = ∞, which is what leaving mu_t unset means: a specimen thicker than the penetration depth needs no correction, since it is exactly degenerate with the scale. So mu_t absent is not mu_t = 0, and mu_t = 0 under bragg_brentano is a specimen of no thickness and raises. Under transmission zero is legal and means a non-absorbing plate. How a refinement works explains why neither coefficient is refinable.

The capillary offsets need a radius. Both default to zero and fixed, because at a synchrotron with a crystal analyser the displacement error is eliminated and freeing them is a deliberate act. Setting or freeing either without goniometer_radius_mm is refused rather than defaulted, since (2.6) divides by R.

estimate_mu_r computes a starting µR from a structure’s composition and the geometry, and returns None rather than raising when it cannot: an element outside the tabulation, a wavelength straddling an edge, or no capillary radius. A refinement does the same calculation itself when mu_r is left None.

RoughnessSuortti carries RoughnessSuortti.a and RoughnessSuortti.b; RoughnessPitschke carries RoughnessPitschke.c and RoughnessPitschke.tau. Both are bragg_brentano only. RoughnessSuortti.kind and RoughnessPitschke.kind name the model, which is how a saved instrument comes back as the one it was rather than as the union’s first member.

The width function

ProfileTCHZ is the instrument’s contribution to every peak’s width. Five parameters, in degrees 2θ throughout.

Field

Type

Default

Meaning

ProfileTCHZ.u

Parameter

0.0 deg², in [−0.05, 1]

Gaussian tan²θ term, (3.1)

ProfileTCHZ.v

Parameter

0.0 deg², in [−0.5, 0.5]

Gaussian tan θ term

ProfileTCHZ.w

Parameter

1e-3 deg², softplus

Gaussian constant term

ProfileTCHZ.x

Parameter

1e-3 deg, softplus

Lorentzian 1/cos θ term, (3.2)

ProfileTCHZ.y

Parameter

0.0 deg, softplus

Lorentzian tan θ term

ProfileTCHZ.shape

"tchz_pv" or "voigt"

"tchz_pv"

pseudo-Voigt or the exact convolution

Match the physics, not the letters. GSAS and FullProf swap the X and Y assignments, so a value copied from another code has to be matched by its θ-dependence: size broadening goes as 1/cos θ and strain as tan θ. Getting this backwards is not a labelling slip, it is a different width function, and this manual has made the mistake itself.

w, x and y are softplus-positive; u and v carry negative lower bounds, so the Gaussian polynomial can bend downward. The quantity that has to stay positive is the total Γ_G² across the measured range rather than each term, so read a negative u or v as a statement about the fitted resolution curve and check the width it produces at both ends of the range.

shape is a compile-time choice, not a refinable one, and both shapes consume the same five widths, so switching never changes the parameter table.

The background

Three models, and the choice is about what the background is allowed to imitate.

BackgroundChebyshev is a shifted-Chebyshev polynomial: BackgroundChebyshev.coefficients is a list of parameters, four fixed zeros by default, and BackgroundChebyshev.with_terms builds n of them free.

BackgroundPSpline is a penalised cubic spline, co-refined with the structure under the second-difference penalty of (7.1). BackgroundPSpline.breakpoints are the knots in 2θ, BackgroundPSpline.coefficients has exactly len(breakpoints) + 2 entries for the clamped cubic basis, BackgroundPSpline.lambda_smooth is the penalty weight, and BackgroundPSpline.air_scatter scales an additive 1/2θ term for the low-angle air rise. BackgroundPSpline.for_range builds uniform knots over a 2θ range.

BackgroundFixedPlusChebyshev holds a fixed estimated curve additively and refines a polynomial on top of it: BackgroundFixedPlusChebyshev.fixed_two_theta and BackgroundFixedPlusChebyshev.fixed_intensity are the curve, and BackgroundFixedPlusChebyshev.chebyshev the refinable part. Use it when you have a measured blank or an estimated baseline, since holding a curve additively keeps the counting statistics intact where subtracting it would not.

from rietx import PatternData
from rietx.schemas.instrument import BackgroundPSpline

bkg = BackgroundPSpline.for_range(2.0, 40.0, knot_step_deg=5.0)
assert len(bkg.coefficients) == len(bkg.breakpoints) + 2

data = PatternData(two_theta=[2.0, 21.0, 40.0], intensity=[900.0, 120.0, 90.0])
assert data.tt()[-1] == 40.0

auto_background sizes a model to a pattern instead: knot spacing from the amorphous-hump score, the air term added only when the diagnostics ask for it, or kind="chebyshev" for an order chosen by BIC.

BackgroundChebyshev.kind, BackgroundPSpline.kind and BackgroundFixedPlusChebyshev.kind are the discriminators. The three models are one JSON union, so the field is what makes a saved instrument come back as the model it was.

A background flexible enough to imitate the peaks is a correctness problem rather than a cosmetic one: it biases displacement parameters up and scales down, and Rwp improves while it happens. That is measured once per fit and reported; Reading the numbers has the table.

Calibrating an instrument once and reusing it

The instrument ⊕ sample split is only worth anything if the instrument half comes from somewhere other than the sample you are measuring. The workflow is three steps, and the middle one is a file.

  1. Calibrate on a line-profile standard with its certified cell held fixed. That is what decorrelates the zero shift from the specimen displacement from the cell. The lab_calibrate plan frees the scale and background, then the zero shift and displacement, then the five width terms, then the emission-line weights and the axial ratios, then the displacement parameters. It never frees phases.*.cell.*, which is the whole point of using a standard.

  2. Freeze with save_instrument_profile, which writes the calibrated state as JSON and strips what belongs to the measurement rather than the goniometer: the background, the specimen displacement and transparency, the surface roughness, and the specimen absorption.

  3. Refine the sample from load_instrument_profile, which returns an Instrument with every stored parameter fixed. The lab_sample_refine plan frees the scale and background, the specimen displacement, the cell, the four sample broadening terms with the anisotropic strain block, the displacement parameters, and the surface roughness. It never frees the zero shift or the instrument widths, which arrived as data.

Step 2 strips those fields for a specific reason. Roughness is a property of how one specimen was packed and pressed, and µt of how thick one mount is, so carrying either into the next sample would pre-bias that sample’s displacement parameters, which is the bias these corrections exist to remove.

Files and projects has the calls and the file. What matters here is what a loaded profile means: the calibration is data, so it arrives fixed, and freeing it again on a sample undoes the decorrelation the standard bought.