Skip to content

Re/06 easydiff schema - #7

Merged
AdamCorrao merged 20 commits into
refactor/multi-enginefrom
re/06-easydiff-schema
Oct 12, 2026
Merged

AdamCorrao merged 20 commits into
refactor/multi-enginefrom
re/06-easydiff-schema

Conversation

@AdamCorrao

@AdamCorrao AdamCorrao commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

re/06: native easydiffraction.rietveld schema, builder and run (easydiffraction 0.21.1)

Branch re/06-easydiff-schema → refactor/multi-engine (base 494a802, 20 commits, tip 799938f).
Not a gate branch. Decisions referenced as A-numbers are in the refactor's decision log (A116, A136, A157–A181; A174–A181 from the PR review).

Summary

  • The easydiffraction package moves to powderline.gateways.easydiffraction (no shim).
  • A native easydiffraction.rietveld recipe (engine schema 1.0.0, on core 1.0.0). It uses easydiffraction's own names and units: Caglioti U/V/W as FWHM² in deg², the Lorentzian X/Y terms, calib_twotheta_offset, …
  • A native builder and run. The 0.26.0 easydiffraction path is unchanged until re/07.
  • easydiffraction is pinned at 0.21.1: the maintainer's lock in 508f39f, plus the gateway spec in e2408f3.
  • The gsasii gateway now reports whether a Rietveld refinement converged (A172).

What the native path does

  • Calculator in the recipe (refinement_controls.calculator: cryspy | crysfml, A162). Validation depends on it:
    • which peak types the calculator computes (cwl-pseudo-voigt, Bérar–Baldinozzi asymmetry for CrysPy, Thompson-Cox-Hastings with FCJ asymmetry for CrysFML);
    • cutoff_fwhm, which only CrysPy uses;
    • which space-group settings it computes correctly. These were established by computation over all 531 settings easydiffraction has, for a general position and for every special Wyckoff site (A163, A170, A174): CrysPy 428, CrysFML 230. CrysPy computes special positions wrongly in nine origin-choice-1 settings, so they are refused for it. The error names a setting the calculator does compute, or the other calculator.
  • Every correction easydiffraction models is a slot (A160): Kα2 doublet, polarization with a monochromator angle, zero, displacement, transparency, cylinder absorption. A left-out block is not modelled.
  • Bounds (A157 B4):
    • a stated bound replaces easydiffraction's physical limit on that side;
    • null keeps the physical limit;
    • a value outside a physical limit is an error, even when fixed;
    • after the run, easydiffraction_parameter_at_limit reports parameters that ended at a limit.
  • Data (A158, A159, EB-72, EB-89):
    • easydiffraction gets exactly the weighted points with min ≤ 2θ ≤ max (fit_limits_on_data);
    • they are handed over as a CIF loop at full precision, written without exponents, which easydiffraction's loop cannot read;
    • a weight above 1e8 on one of those points is an error, because easydiffraction would silently replace σ < 1e-4 by 1.0;
    • a negative intensity on one of those points is an error, because easydiffraction refuses it (A177).
  • Atoms (A116):
    • x/y/z, occupancy, Uiso and Uij are refinable. Each symmetry tie group becomes one easydiffraction parameter. After the build, easydiffraction's multiplicities and coordinates are checked against core's.
    • With CrysPy, an atom on a special position with a free coordinate must be written in easydiffraction's Wyckoff template pattern. CrysPy computes some other equivalent writings wrongly (and the rule is conservative: it also refuses some writings CrysPy gets right); the run refuses them before calculating and names the position to write (A164).
    • easydiffraction stores some positions in its own writing of the same site, e.g. (0.137, 0.863, z) on (x, −x, z) as (0.137, −0.137, z). That is the same atom: it is accepted with the warning easydiffraction_position_translated, and bounds and reports stay in the recipe's writing (A176).
    • Uaniso is refused with CrysFML, which is handed only B = 8π²·Ueq (A169).
    • With CrysPy, anisotropic atoms are handed over first, because easydiffraction 0.21.1 ignores a refined Uij that follows an isotropic atom (A169).
  • Results:
    • standard rwp/r_exp/gof/chi2_red from core fitstats over the window (Rwp equals easydiffraction's);
    • easydiffraction's own values, the calculator, the peak type, parameters_requested and parameters_varied under engine_details;
    • the background column is easydiffraction's own;
    • CrysFML reports no reflection list, so no peak-list reports are written with it (A165);
    • a non-finite calculated value, refined value or reflection-list value is a divergence error.
  • Warnings:
    • easydiffraction_parameters_not_varied;
    • easydiffraction_parameter_at_limit (within 1e-6 relative of the limit; at a limit of 0, within 1e-6 of the starting value, A181);
    • easydiffraction_phase_contributes_nothing (informative);
    • easydiffraction_fit_not_converged (informative, values kept, A166);
    • easydiffraction_negative_width, when the refined profile makes a width negative within 1° of the window (widened by the position corrections; A171, A178). A stated profile that does so is a validation error, because easydiffraction then computes a wrong pattern without a message, also from a reflection just outside the window.
    • easydiffraction_reflections_not_available (CrysFML);
    • easydiffraction_crysfml_start_dependent on every CrysFML refinement (A175): CrysFML computes in single precision, below lmfit's finite-difference step, so the refined values, Rwp and ESDs can depend on the starting values (in the LaB6 test, Rwp 6.15–6.79 % for starts differing by 1e-6–1e-4; the cell barely moves);
    • easydiffraction_position_translated (A176).
  • gsasii (A172): engine_details['converged'] comes from GSAS-II's Rvals['converged']. When it is False, the warning gsasii_fit_not_converged is given and the values are kept. Single peak fitting reports None: GSAS-II's DoPeakFit keeps no convergence flag.

Engine findings worth knowing (easydiffraction 0.21.1)

  • CrysPy:

    • wrong intensities for some equivalent writings of a special position with a free coordinate (LaB6's B written as (½, ½, x));
    • a refined anisotropic ADP has no effect when an isotropic atom precedes it;
    • P 31 is computed with P 32's operators (same powder pattern, the other hand per reflection);
    • 53 settings crash.
  • CrysPy also computes special positions wrongly in nine origin-choice-1 settings, even in the template writing.

  • CrysFML:

    • is handed only the short space-group symbol, so non-default settings are read in the default one;
    • ignores anisotropic ADPs;
    • computes in single precision: its pattern does not change for parameter steps below about 1e-7 relative, so lmfit's fits stop at start-dependent points.
  • Data: easydiffraction refuses negative measured intensities.

  • Both calculators: a negative peak width gives a wrong pattern without a message.

  • Cell constraints: they reset the unique angle of c- and a-unique monoclinic cells.

  • Cross-engine LaB6 cell, refined in both (GSAS-II 4.157216(21) Å):

    easydiffraction profile a (Å) vs GSAS-II
    CrysFML TCH + FCJ 4.157175(18) −1.5σ (the CrysFML fit is start-dependent: 4.15716–4.15718 Å)
    CrysPy pseudo-Voigt (no asymmetry) 4.157495(17) +67 ppm
    CrysFML pseudo-Voigt (no asymmetry) 4.157593(20) +91 ppm

    The offset is the missing axial asymmetry, whichever calculator runs the profile.

  • DRX_33, two phases: agrees with TOPAS 6 native: cubic a 4.18201 vs 4.18244 Å, Rwp 7.25 vs 7.31 %.

  • 0.20.1 → 0.21.1: the legacy LaB6 example's Rwp is unchanged (9.633 %); some ESDs change.

Tests

  • Default env: 3037 passed, 19 skipped. The skips are 14 easydiffraction-only tests and 5 TOPAS DRX_33-only golden tests. Every base test ID still passes except four renamed by the package move and the new version spec.
  • easydiff env (test_easydiff_* test_easydiffraction_* test_gateways test_phase_canary): 265 passed, 2 skipped (the "easydiffraction not installed" tests).
  • New test modules:
    • tests/test_easydiffraction_schema.py;
    • tests/test_easydiffraction_native.py: the builder, the run, an A127 check that every free parameter changes the pattern on lmfit's own update path, divergence, warnings, and four fixtures (three LaB6, one two-phase DRX_33) with goldens;
    • tests/test_easydiffraction_space_groups.py: the space-group table is regenerated and compared, and a sample of settings is re-verified by computation.
  • An independent review (tasks/re06-pr-review-record.md in the devkit) checked every decision by disabling its code (each caught by a test) and led to the fixes in the last six commits (A174–A181).
  • Generators: scripts/generate_easydiffraction_space_groups.py and scripts/verify_easydiffraction_settings.py (about 1.5 h with --jobs 4; more workers need more than ~1.5 GB each).
  • No GSAS-II frozen or hashed module is touched. examples/ is unchanged.

For reviewers

  • After checkout, refresh the entry points (pixi reinstall powderline, or pip install --no-deps --no-build-isolation --no-index -e . per env), because the package moved.

  • The fixtures' derivations from GSAS-II's instrument terms are in tests/data/easydiffraction/native/README.md. They cover:

    • polarization p = 1 − P;
    • the Lorentzian X/Y role swap;
    • FCJ S/L = H/L = SH/L / 2.

    The v0.1.1 path got these wrong; it stays as is until re/07.

  • The CrysPy LaB6 fixture's fit refines X = −0.098, so its golden carries easydiffraction_negative_width. This is intentional: the start is valid and the warning describes the output.

Follow-ups (not in this PR)

  • re/07: remove the legacy easydiffraction path, migrate the LaB6 example (write B as (0.2021, ½, ½) and state the calculator).
  • TOPAS 7: a convergence signal, after the refactor.
  • Upstream reports for easydiffraction, GSAS-II and TOPAS are drafted (with standalone reproductions for easydiffraction). Filing them is the maintainer's call.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-sonnet-5-5

…sydiffraction (A24, A136)

src/powderline/easydiff/ -> src/powderline/gateways/easydiffraction/,
verbatim (git rename, no content change beyond the module path). The entry
point (powderline.gateways:easydiffraction) and every src/tests import use
the new path; no powderline.easydiff shim (A136). No pixi task names the
module. Prose docs citing the old path are left for re/10; the pixi env
keeps its name `easydiff` until re/08 (A42).

An existing checkout needs `pixi reinstall powderline` so the entry-point
metadata points at the new module.

Suites: default 2954 passed, 9 skipped (baseline 494a802 identical except
four parametrized test_gateways IDs that contain the module path); easydiff
env tests/test_easydiff_* 55 passed, IDs identical.

Assisted-by: claude-code:claude-opus-5-5
Engine schema 1.0.0 on the core schema: EasydiffractionPhase =
Phase[BoundedRefinableParameter] + scale; easydiffraction's own names and
units (A4); the instrument in gsasii's A103 layout with every correction
easydiffraction 0.21.1 models for CW X-ray powder data as a slot (A160):
Kα2 doublet, polarization + monochromator angle, zero, displacement,
transparency, cylinder absorption, and the three peak types (pseudo-Voigt,
Bérar-Baldinozzi, TCH + FCJ) as a discriminated union.
refinement_controls carries the calculator (cryspy | crysfml), the minimizer
(lmfit leastsq only) and its stopping criteria (A162).

Rules: a setting easydiffraction has (EB-42; committed table generated
from the library by scripts/generate_easydiffraction_space_groups.py and
checked by a test where that version is installed); values inside
easydiffraction's physical limits (EB-71); wavelength > 0; scale >= 0
(A161); weights <= 1e8 on the points handed to the engine (A159, EB-73);
peak type and cutoff_fwhm per calculator; Kα2 pair stated together.

The gateway routes easydiffraction.* recipes to the native schema in
validate()/capabilities(); the native run comes with the builder.
EasydiffractionPhase is registered in the A122 canary. Hand-derived
fixtures with their derivations: tests/data/easydiffraction/native/.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-sonnet-5-5
…tveld (re/06)

native_builder: builds the easydiffraction project from the validated
model (A39). The window's weighted points (fit_limits_on_data) go in as a
CIF data loop at full precision (A158; the ASCII reader rounds 2theta to 4
decimals, EB-72). Calculator, then peak type, then profile (EB-76); every
instrument slot from the model; exact model coordinates. One free parameter
per refined tie group, the member easydiffraction leaves free (an error if
its constraints disagree with core's); stated bounds become fit_min/fit_max,
a null side keeps the physical limit (A157 B4). After the build:
easydiffraction's multiplicity and coordinates must equal core's (EB-47),
and with CrysPy an atom on a special position with a free coordinate must be
written in the pattern of easydiffraction's template, since CrysPy computes
wrong structure factors for other images (EB-77; the error names the
equivalent position).

native_run: lmfit with the recipe's stopping criteria, or a calculation
when nothing is refined; strict reading (non-finite = divergence, A130,
A154); core fitstats over the handed points (Rwp equals easydiffraction's);
P = easydiffraction's n_free_parameters (lmfit's varied count); y_bkg =
the engine's background; the shared report writers; warnings for not
varied, at limit, phase contributes nothing, not converged, and no
reflection list with CrysFML (EB-79).

Schema: a space-group setting must be one the chosen calculator computes
correctly (EB-80, proposed A163): committed space_group_support.py, written
by scripts/verify_easydiffraction_settings.py (computed for all 531
settings: CrysPy 437, CrysFML 227); a test re-verifies a sample.

The gateway routes easydiffraction.* runs to the native path. Tests: 46
builder/run tests and golden comparisons (tests/data/easydiffraction/
native/golden, written by the devkit's probes/re06/make_goldens.py); they
run where easydiffraction 0.21.1 is installed.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-sonnet-5-5
Follows the maintainer's pixi.toml/lock bump (508f39f). ENGINE_VERSION_SPEC
moves from ">=0.20.1,<0.21" to "==0.21.1"; the version tests fake 0.20.1
(now outside the spec) and 0.21.1; the native tests no longer bypass the
version check.

The legacy LaB6 example's Rwp is unchanged (0.20.1 9.633040 %, 0.21.1
9.633031 %): prof_wr_factor was already 1/sigma^2-weighted; 0.21.0's #224
fixed the printed table's weighted R only (EB-81, devkit).

easydiff env (0.21.1): test_easydiff_* test_easydiffraction_* test_gateways
test_phase_canary 226 passed, 2 skipped.

Assisted-by: claude-code:claude-opus-5-5
- Uaniso with calculator crysfml is a validation error: easydiffraction
  hands CrysFML only B = 8 pi^2 Ueq, so the tensor would have no effect.
- The builder hands easydiffraction the anisotropic atoms first. In
  easydiffraction 0.21.1 the CrysPy update during a fit writes a refined
  tensor into the column of its place among the anisotropic atoms, not its
  own (_update_aniso_beta), so an anisotropic atom after an isotropic one
  kept its starting tensor and lmfit reported it had no effect (EB-86).
  Reports keep the recipe's atom order.
- A127 test: with every refinable flagged, each free parameter changes the
  pattern computed on lmfit's own update path (both calculators); without
  the reordering it names B's U11 and U22. A CrysPy fit refines B's Uij
  with ESDs.

Assisted-by: claude-code:claude-opus-5-5
…ses (A166)

The run stays a success with the values reported; the warning now says so
and points at too few iterations or poorly conditioned parameters.

Assisted-by: claude-code:claude-opus-5-5
…170)

The generator tested CrysFML only where CrysPy passed, so a setting CrysPy
crashes on or computes as another hand was refused for CrysFML too.

- CrysPy: per-reflection F^2 against the ITC reference, unchanged (strict:
  sensitive to the hand; P 31, which CrysPy computes with P 32's operators,
  stays refused).
- CrysFML: its pattern against its own pattern of the orbit written out in
  P 1 (no symmetry handling involved); an engine error fails that
  calculator only.

Regenerated support: CrysPy 437 (unchanged), CrysFML 227 -> 230
(+ P 31, P 6/m, F d -3 c:2). The sample test re-verifies those three.
Passes R < 1e-4, failures >= 0.11 for CrysFML; R_PASS 0.03 unchanged.

Assisted-by: claude-code:claude-opus-5-5
drx33_two_phase_cryspy.json: the example_DRX_33 data with its two phases,
cubic DRX_33 (F m -3 m, mixed 4a site) and Li4MgWO6 (C 1 2/m 1), CrysPy
pseudo-Voigt, 18 parameters (profile, background, scales, cells). Test:
both structures linked, every parameter varied with an ESD, per-phase
reports; golden for the built project and the run (Rwp 7.248 %).

Assisted-by: claude-code:claude-opus-5-5
… after a run (A171)

Where the Gaussian FWHM^2 (U tan^2 + V tan + W) or the Lorentzian FWHM
(X tan + Y / cos) is negative, easydiffraction 0.21.1 computes a wrong
pattern without a message: CrysFML drops those peaks, CrysPy distorts them
(EB-87). A stated profile that makes a width negative at a point handed to
the engine is a validation error naming the 2theta range; a refined one
gets the warning easydiffraction_negative_width, values kept.

Also: pydantic's error templates take no format specs, so the weight error
printed "sigma {sigma:.3g}" literally; values are formatted before.

Assisted-by: claude-code:claude-opus-5-5
…re/06)

Assisted-by: claude-code:claude-opus-5-5
easydiffraction 0.21.1's CIF data loop cannot read exponent notation
(5e-05, 5E-05, 1e+16: an opaque TypeError), and repr() writes it for
|v| < 1e-4 or >= 1e16, so normalized intensities (allowed with
sigma >= 1e-4, A159) crashed the run. Numbers are now written positional
at full precision (numpy format_float_positional, unique: exact round
trip). Test: intensities ~1e-5 with sigma 1e-4 reach the engine exactly.

Assisted-by: claude-code:claude-opus-5-5
…dth warning (A171)

The refined CrysPy pseudo-Voigt profile has broad_lorentz_x = -0.098(4),
so X tan + Y / cos is negative from 11.2 to 15 deg 2theta and the run now
warns easydiffraction_negative_width. Values unchanged (regenerated with
make_goldens.py lab6_cryspy_pv.json; only the warnings list differs).

Assisted-by: claude-code:claude-opus-5-5
fit_report reads GSAS-II's Rvals['converged'] (the last cycle's chi^2
change below its tolerance) into engine_details['converged'] and warns
gsasii_fit_not_converged when it is False, values kept (as A166 for
easydiffraction; not in a simulation). Single peak fitting reports
converged None: DoPeakFit keeps no convergence flag (EB-85). fit_report.py
is neither frozen nor hashed.

Tests: fit_report on a faked run (False warns, True and simulation do
not); LaB6 with 2 cycles is a success with the warning; the 5-cycle LaB6
converges; SPF reports None. Default suite 3029 passed, 19 skipped.

Assisted-by: claude-code:claude-opus-5-5
…x safeguards (re/06 PR review N1, N5)

A non-finite value in easydiffraction's reflection list (d-spacing, 2theta,
F^2) is now the divergence error, as the profile and refined values are
(A154). New tests for safeguards that no test caught when disabled: the
EB-47 site check, interior zero-weight points not handed, the RAISE
reaction against ED_LOG_REACTION=WARN (EB-48), the not-varied warning
(A128), y_bkg = easydiffraction's own Chebyshev (A157 B5), and the
divergence error for a non-finite refined value or reflection.

Assisted-by: claude-code:claude-opus-5-5
…efuses nine origin-1 settings (A174)

CrysPy computes template-form special positions with a free coordinate
wrongly in nine origin-choice-1 settings although their general position
is right (re/06 PR review S1, EB-77). The settings verification now also
compares, per calculator, every special Wyckoff site's pattern with the
calculator's own P 1 expansion (raw R < 1e-3; CrysFML's general position
by raw R too, N7), and the support table is regenerated: CrysPy 437 -> 428
(F d d d:1, F d -3 m:1, F d -3:1, P n -3 m:1, P 4/n n c:1, P 42/n n m:1,
I 41/a:1, I 41/a m d:1, I 41/a c d:1), CrysFML 230 unchanged. The error
names the origin-2 setting.

Assisted-by: claude-code:claude-opus-5-5
…within 1 deg of the window (A177, A178)

A177 (S4, EB-91): easydiffraction refuses a negative measured intensity,
so a negative Itth on a point handed to it is now a validation error at
xrd_data.Itth[i] instead of a raw TypeError at run time; points outside
the window or with zero weight are not checked.

A178 (S5): the stated (and, after the run, refined) peak widths must be
>= 0 on the handed points and on a 0.01 deg grid over the window widened
by 1 deg plus the size of the zero, displacement and transparency
corrections: easydiffraction computes reflections just outside the window,
and a negative width there moved the first window point by 12 sigma in the
review's probe.

Assisted-by: claude-code:claude-opus-5-5
…; CrysFML warns; golden tolerances (A175, A176, A179)

A176 (Q3, EB-74): easydiffraction stores some template-pattern positions in
its own writing, e.g. (0.137, 0.863, z) on (x, -x, z) as (0.137, -0.137, z):
the same atom and pattern. The site check now accepts a lattice translation
(multiplicity and position otherwise unchanged), records it, moves stated
bounds of a free coordinate with it, reports refined coordinates in the
recipe's writing, and warns easydiffraction_position_translated. Any other
move, or another multiplicity, stays an error (EB-47).

A175 (Q2, EB-90): every CrysFML refinement warns
easydiffraction_crysfml_start_dependent: CrysFML computes in single
precision, below lmfit's finite-difference step, so refined values, Rwp and
ESDs can depend on the starting values. Its refinement golden now compares
only stop-independent quantities (counts, warnings, cell within 1e-4); a new
CrysFML calculation fixture carries CrysFML's numbers.

A179 (S6): golden tolerances follow docs/regression-tolerance.md (values,
ESDs, statistics rtol 1e-3, a value also within 1 % of its ESD; cell
1e-4); goldens record the easydiffraction, cryspy, crysfml, lmfit, numpy
and scipy versions (regenerated: no value changed).

Assisted-by: claude-code:claude-opus-5-5
…74-A178)

Settings verified on special sites (CrysPy 428, CrysFML 230), widths checked
within 1 deg of the window, negative intensities refused, positions held as
a lattice translation accepted with a warning, CrysFML's start-dependence
warning, reflection-list values read strictly.

Assisted-by: claude-code:claude-opus-5-5
…ative to the starting value (A181)

easydiffraction_parameter_at_limit used an absolute 1e-12 at a limit of 0,
which has no physical scale: a phase scale is ~1e-6, a Uiso ~1e-2. A value
now ends at a zero limit when it is within 1e-6 of the parameter's starting
value (1e-12 absolute only if it also started at 0), as nonzero limits use
1e-6 of the limit (re/06 PR review N4).

Assisted-by: claude-code:claude-opus-5-5
@AdamCorrao
AdamCorrao merged commit 0e34f54 into refactor/multi-engine Oct 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant