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 |
|---|---|---|---|
|
float |
required |
the current value, in |
|
bool |
|
whether the least-squares problem is free in it |
|
float |
|
inclusive lower bound |
|
float |
|
inclusive upper bound |
|
str or None |
|
a label, not a conversion: nothing rescales by it |
|
float or None |
|
the esd, written by the fit |
|
|
|
internal reparameterisation, (9.2) |
|
None |
|
reserved, not implemented, and must stay |
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 |
|---|---|---|---|
|
list[float] |
required |
2θ in degrees, strictly increasing |
|
list[float] |
required |
measured intensity, same length |
|
list[float] or None |
|
per-point esd from the file; |
|
list[tuple[float, float]] |
|
2θ intervals to leave out of the fit |
|
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 |
|---|---|---|---|
|
str |
required |
a label, and the key an export writes |
|
str |
required |
Hermann-Mauguin symbol or a number as a string, resolved by gemmi |
|
|
required |
lengths and angles |
|
list[ |
required |
the asymmetric unit, at least one |
|
|
1.0, fixed, softplus |
this phase’s contribution to the total intensity |
|
|
0.0 deg, softplus |
Lorentzian size broadening, 1/cos θ |
|
|
0.0 deg, softplus |
Lorentzian strain broadening, tan θ |
|
|
0.0 deg², softplus |
Gaussian size broadening, 1/cos²θ |
|
|
0.0 deg², softplus |
Gaussian strain broadening, tan²θ |
|
|
0.0, fixed, softplus |
secondary extinction, (5.11); 0 is E ≡ 1 exactly |
|
|
|
single-axis March-Dollase, (5.13) |
|
|
|
anisotropic strain, width per hkl, (6.2) |
|
float or None |
|
Brindley microabsorption input, (5.16); a plain float, never refined |
|
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 |
|---|---|---|---|
|
|
required |
axis lengths in Å |
|
|
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 |
|---|---|---|---|
|
str |
required |
the site label, as a CIF spells it |
|
str |
required |
the scattering species: |
|
|
required |
fractional coordinates |
|
|
1.0, in [0, 1.5] |
site occupancy |
|
|
0.5 Ų, in [0, 25] |
isotropic displacement, B = 8π²·U |
|
|
|
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 |
|
three and one |
|
two and two |
|
two and one and one |
|
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 |
|---|---|---|---|
|
|
required |
radiation: wavelengths, line weights, polarisation |
|
|
capillary, no aberrations |
how the specimen sits in the beam |
|
|
0.0 deg, in [−0.5, 0.5] |
a constant 2θ offset, the one position error every geometry has |
|
|
see below |
the instrumental width function |
|
one of three |
|
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 |
|---|---|---|---|
|
capillary |
one wavelength you pass |
|
|
flat plate, reflection |
an anode name, Kα1 + Kα2 |
goniometer radius 217.5 mm, |
|
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 |
|---|---|---|---|
|
list[ |
required |
one entry per emission line, at least one |
|
|
0.5 |
the fraction K of (5.1); 0.5 is an unpolarised beam |
|
|
on |
anomalous scattering, (4.6) |
|
|
|
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 |
|---|---|---|---|
|
literal |
|
which of the three geometries |
|
float or None |
|
R in mm; required for |
|
|
0.0 |
Finger-Cox-Jephcoat axial divergence: sample and detector half-lengths over R |
|
float |
0.6 |
solid fraction of the specimen, an absorption estimator input |
A capillary, kind="debye_scherrer":
Field |
Type |
Default |
Meaning |
|---|---|---|---|
|
|
0.0 mm |
the sin 2θ half of (2.6), positive downstream |
|
|
0.0 mm |
the cos 2θ half, positive toward increasing 2θ |
|
float or None |
|
µ·R of the packed specimen, (5.4); 0.0 is off exactly |
|
float or 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 |
|---|---|---|---|
|
|
0.0 mm |
specimen surface off the goniometer axis, cos θ, (2.4) |
|
|
0.0 |
penetration into the specimen, (2.5) |
|
float or None |
|
|
|
float or None |
|
specimen thickness in mm, an estimator input for µt |
|
|
|
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 |
|---|---|---|---|
|
|
0.0 deg², in [−0.05, 1] |
Gaussian tan²θ term, (3.1) |
|
|
0.0 deg², in [−0.5, 0.5] |
Gaussian tan θ term |
|
|
1e-3 deg², softplus |
Gaussian constant term |
|
|
1e-3 deg, softplus |
Lorentzian 1/cos θ term, (3.2) |
|
|
0.0 deg, softplus |
Lorentzian tan θ term |
|
|
|
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.
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_calibrateplan 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 freesphases.*.cell.*, which is the whole point of using a standard.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.Refine the sample from
load_instrument_profile, which returns anInstrumentwith every stored parameter fixed. Thelab_sample_refineplan 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.