Version: 1.0
Date: 2026-03-24
Status: Living document β updated as the project evolves
EasyDiffraction is a Python library for crystallographic diffraction
analysis (Rietveld refinement, pair-distribution-function fitting,
etc.). It models the domain using CIF-inspired abstractions β
datablocks, categories, and parameters β while providing a high-level,
user-friendly API through a single Project faΓ§ade.
Every experiment is fully described by four orthogonal axes:
| Axis | Options | Enum |
|---|---|---|
| Sample form | powder, single crystal | SampleFormEnum |
| Scattering type | Bragg, total (PDF) | ScatteringTypeEnum |
| Beam mode | constant wavelength, time-of-flight | BeamModeEnum |
| Radiation probe | neutron, X-ray | RadiationProbeEnum |
Planned extensions: 1D / 2D data dimensionality, polarised / unpolarised neutron beam.
External libraries perform the heavy computation:
| Engine | Scope |
|---|---|
cryspy |
Bragg diffraction |
crysfml |
Bragg diffraction |
pdffit2 |
Total scattering |
All core types live in core/ which contains only base classes and
utilities β no domain logic.
GuardedBase # Controlled attribute access, parent linkage, identity
βββ CategoryItem # Single CIF category row (e.g. Cell, Peak, Instrument)
βββ CollectionBase # Ordered nameβitem container
β βββ CategoryCollection # CIF loop (e.g. AtomSites, Background, Data)
β βββ DatablockCollection # Top-level container (e.g. Structures, Experiments)
βββ DatablockItem # CIF data block (e.g. Structure, Experiment)CollectionBase provides a unified dict-like API over an ordered item
list with name-based indexing. All key operations β __getitem__,
__setitem__, __delitem__, __contains__, remove() β resolve keys
through a single _key_for(item) method that returns
category_entry_name for category items or datablock_entry_name for
datablock items. Subclasses CategoryCollection and
DatablockCollection inherit this consistently.
GuardedBase is the root ABC. It enforces that only declared
@property attributes are accessible publicly:
__getattr__rejects any attribute not declared as a@propertyon the class hierarchy. Shows diagnostics with closest-match suggestions on typos.__setattr__distinguishes:- Private (
_-prefixed) β always allowed, no diagnostics. - Read-only public (property without setter) β blocked with a clear error.
- Writable public (property with setter) β goes through the property setter, which is where validation happens.
- Unknown β blocked with diagnostics showing allowed writable attrs.
- Private (
- Parent linkage β when a
GuardedBasechild is assigned to another, the child's_parentis set automatically, forming an implicit ownership tree. - Identity β every instance gets an
_identity: Identityobject for lazy CIF-style name resolution (datablock_entry_name,category_code,category_entry_name) by walking the_parentchain.
Key design rule: if a parameter has a public setter, it is writable for the user. If only a getter β it is read-only. If internal code needs to set it, a private method (underscore prefix) is used. See Β§ 2.2.1 below for the full pattern.
Every public parameter or descriptor exposed on a GuardedBase subclass
follows one of two patterns:
| Kind | Getter | Setter | Internal mutation |
|---|---|---|---|
| Editable | yes | yes | Via the public setter |
| Read-only | yes | no | Via a private _set_<name> method |
Editable property β the user can both read and write the value. The
setter runs through GuardedBase.__setattr__ and into the property
setter, where validation happens:
@property
def name(self) -> str:
"""Human-readable name of the experiment."""
return self._name
@name.setter
def name(self, new: str) -> None:
self._name = newRead-only property β the user can read but cannot assign. Any
attempt to set the attribute is blocked by GuardedBase.__setattr__
with a clear error message. If internal code (factory builders, CIF
loaders, etc.) needs to set the value, it calls a private _set_<name>
method instead of exposing a public setter:
@property
def sample_form(self) -> StringDescriptor:
"""Sample form descriptor (read-only for the user)."""
return self._sample_form
def _set_sample_form(self, value: str) -> None:
"""Internal setter used by factory/CIF code during construction."""
self._sample_form.value = valueWhy this matters:
GuardedBase.__setattr__uses the presence of a setter to decide writability. Adding a setter "just for internal use" would open the attribute to users.- Private
_set_<name>methods keep the public API surface minimal and intention-clear, while remaining greppable and type-safe. - The pattern avoids string-based dispatch β every mutator has an explicit named method.
| Aspect | CategoryItem |
CategoryCollection |
|---|---|---|
| CIF analogy | Single category row | Loop (table) of rows |
| Examples | Cell, SpaceGroup, Instrument, Peak | AtomSites, Background, Data, LinkedPhases |
| Parameters | All GenericDescriptorBase attrs |
Aggregated from all child items |
| Serialisation | as_cif / from_cif |
as_cif / from_cif |
| Update hook | _update(called_by_minimizer=) |
_update(called_by_minimizer=) |
| Update priority | _update_priority (default 10) |
_update_priority (default 10) |
| Display | show() on concrete subclasses |
show() on concrete subclasses |
| Building items | N/A | add(item), create(**kwargs) |
Update priority: lower values run first. This ensures correct execution order within a datablock (e.g. background before data).
| Aspect | DatablockItem |
DatablockCollection |
|---|---|---|
| CIF analogy | A single data_ block |
Collection of data blocks |
| Examples | Structure, BraggPdExperiment | Structures, Experiments |
| Category discovery | Scans vars(self) for categories |
N/A |
| Update cascade | _update_categories() β sorted by priority |
N/A |
| Parameters | Aggregated from all categories | Aggregated from all datablocks |
| Fittable params | N/A | Non-constrained Parameters |
| Free params | N/A | Fittable + free == True |
| Dirty flag | _need_categories_update |
N/A |
When any Parameter.value is set, it propagates
_need_categories_update = True up to the owning DatablockItem.
Serialisation (as_cif) and plotting trigger _update_categories() if
the flag is set.
GuardedBase
βββ GenericDescriptorBase # name, value (validated via AttributeSpec), description
βββ GenericStringDescriptor # _value_type = DataTypes.STRING
βββ GenericNumericDescriptor # _value_type = DataTypes.NUMERIC, + units
βββ GenericParameter # + free, uncertainty, fit_min, fit_max, constrained, uidCIF-bound concrete classes add a CifHandler for serialisation:
| Class | Base | Use case |
|---|---|---|
StringDescriptor |
GenericStringDescriptor |
Read-only or writable text |
NumericDescriptor |
GenericNumericDescriptor |
Read-only or writable number |
Parameter |
GenericParameter |
Fittable numeric value |
Initialisation rule: all Parameters/Descriptors are initialised with
their default values from value_spec (an AttributeSpec) without
any validation β we trust internal definitions. Changes go through
public property setters, which run both type and value validation.
Mixin safety: Parameter/Descriptor classes must not have init
arguments so they can be used as mixins safely (e.g.
PdTofDataPointMixin).
AttributeSpec bundles default, data_type, validator,
allow_none. Validators include:
| Validator | Purpose |
|---|---|
TypeValidator |
Checks Python type against DataTypes |
RangeValidator |
ge, le, gt, lt bounds checking |
MembershipValidator |
Value must be in an allowed set |
RegexValidator |
Value must match a pattern |
An experiment's type is defined by the four enum axes and is immutable
after creation. This avoids the complexity of transforming all
internal state when the experiment type changes. The type is stored in
an ExperimentType category with four StringDescriptors validated by
MembershipValidators. Public properties are read-only; factory and
CIF-loading code use private setters (_set_sample_form,
_set_beam_mode, _set_radiation_probe, _set_scattering_type) during
construction only.
DatablockItem
βββ ExperimentBase # name, type: ExperimentType, as_cif
βββ PdExperimentBase # + linked_phases, excluded_regions, peak, data
β βββ BraggPdExperiment # + instrument, background (both via factories)
β βββ TotalPdExperiment # (no extra categories yet)
βββ ScExperimentBase # + linked_crystal, extinction, instrument, data
βββ CwlScExperiment
βββ TofScExperimentEach concrete experiment class carries:
type_info: TypeInfoβ tag and description for factory lookupcompatibility: Compatibilityβ which enum axis values it supports
Every experiment owns its categories as private attributes with public read-only or read-write properties:
# Read-only β user cannot replace the object, only modify its contents
experiment.linked_phases # CategoryCollection
experiment.excluded_regions # CategoryCollection
experiment.instrument # CategoryItem
experiment.peak # CategoryItem
experiment.data # CategoryCollection
# Type-switchable β recreates the underlying object
experiment.background_type = 'chebyshev' # triggers BackgroundFactory.create(...)
experiment.peak_profile_type = 'thompson-cox-hastings' # triggers PeakFactory.create(...)
experiment.extinction_type = 'shelx' # triggers ExtinctionFactory.create(...)
experiment.linked_crystal_type = 'default' # triggers LinkedCrystalFactory.create(...)
experiment.excluded_regions_type = 'default' # triggers ExcludedRegionsFactory.create(...)
experiment.linked_phases_type = 'default' # triggers LinkedPhasesFactory.create(...)Type switching pattern: expt.background_type = 'chebyshev' rather
than expt.background.type = 'chebyshev'. This keeps the API at the
experiment level and makes it clear that the entire category object is
being replaced.
DatablockItem
βββ Structure # name, cell, space_group, atom_sitesA Structure contains three categories:
Cellβ unit cell parameters (CategoryItem)SpaceGroupβ symmetry information (CategoryItem)AtomSitesβ atomic positions collection (CategoryCollection)
Symmetry constraints (cell metric, atomic coordinates, ADPs) are applied
via the crystallography module during _update_categories().
All factories inherit from FactoryBase, which provides:
| Feature | Method / Attribute | Description |
|---|---|---|
| Registration | @Factory.register |
Class decorator, appends to _registry |
| Supported map | _supported_map() |
{tag: class} from all registered classes |
| Creation | create(tag) |
Instantiate by tag string |
| Default resolution | default_tag(**conditions) |
Largest-subset matching on _default_rules |
| Context creation | create_default_for(**cond) |
Resolve tag β create |
| Filtered query | supported_for(**filters) |
Filter by Compatibility and CalculatorSupport |
| Display | show_supported(**filters) |
Pretty-print table of type + description |
| Tag listing | supported_tags() |
List of all registered tags |
Each __init_subclass__ gives every factory its own independent
_registry and _default_rules.
_default_rules maps frozensets of (axis_name, enum_value) tuples to
tag strings (preferably enum values for type safety):
class PeakFactory(FactoryBase):
_default_rules = {
frozenset({
('scattering_type', ScatteringTypeEnum.BRAGG),
('beam_mode', BeamModeEnum.CONSTANT_WAVELENGTH),
}): PeakProfileTypeEnum.PSEUDO_VOIGT,
frozenset({
('scattering_type', ScatteringTypeEnum.BRAGG),
('beam_mode', BeamModeEnum.TIME_OF_FLIGHT),
}): PeakProfileTypeEnum.PSEUDO_VOIGT_IKEDA_CARPENTER,
frozenset({
('scattering_type', ScatteringTypeEnum.TOTAL),
}): PeakProfileTypeEnum.GAUSSIAN_DAMPED_SINC,
}Resolution uses largest-subset matching: the rule whose frozenset is
the biggest subset of the given conditions wins. frozenset() acts as a
universal fallback.
Every @Factory.register-ed class carries three frozen dataclass
attributes:
@PeakFactory.register
class CwlPseudoVoigt(PeakBase, CwlBroadeningMixin):
type_info = TypeInfo(
tag='pseudo-voigt',
description='Pseudo-Voigt profile',
)
compatibility = Compatibility(
scattering_type=frozenset({ScatteringTypeEnum.BRAGG}),
beam_mode=frozenset({BeamModeEnum.CONSTANT_WAVELENGTH}),
)
calculator_support = CalculatorSupport(
calculators=frozenset({CalculatorEnum.CRYSPY, CalculatorEnum.CRYSFML}),
)| Metadata | Purpose |
|---|---|
TypeInfo |
Stable tag for lookup/serialisation + human description |
Compatibility |
Which enum axis values this class works with |
CalculatorSupport |
Which calculation engines support this class |
Concrete classes use @Factory.register decorators. To trigger
registration, each package's __init__.py must explicitly import
every concrete class:
# datablocks/experiment/categories/background/__init__.py
from .chebyshev import ChebyshevPolynomialBackground
from .line_segment import LineSegmentBackground| Factory | Domain | Tags resolve to |
|---|---|---|
BackgroundFactory |
Background categories | LineSegmentBackground, ChebyshevPolynomialBackground |
PeakFactory |
Peak profiles | CwlPseudoVoigt, TofPseudoVoigtIkedaCarpenter, β¦ |
InstrumentFactory |
Instruments | CwlPdInstrument, TofPdInstrument, β¦ |
DataFactory |
Data collections | PdCwlData, PdTofData, ReflnData, TotalData |
ExtinctionFactory |
Extinction models | ShelxExtinction |
LinkedCrystalFactory |
Linked-crystal refs | LinkedCrystal |
ExcludedRegionsFactory |
Excluded regions | ExcludedRegions |
LinkedPhasesFactory |
Linked phases | LinkedPhases |
ExperimentTypeFactory |
Experiment descriptors | ExperimentType |
CellFactory |
Unit cells | Cell |
SpaceGroupFactory |
Space groups | SpaceGroup |
AtomSitesFactory |
Atom sites | AtomSites |
AliasesFactory |
Parameter aliases | Aliases |
ConstraintsFactory |
Parameter constraints | Constraints |
FitModeFactory |
Fit-mode category | FitMode |
JointFitExperimentsFactory |
Joint-fit weights | JointFitExperiments |
CalculatorFactory |
Calculation engines | CryspyCalculator, CrysfmlCalculator, PdffitCalculator |
MinimizerFactory |
Minimisers | LmfitMinimizer, DfolsMinimizer, β¦ |
Note:
ExperimentFactoryandStructureFactoryare builder factories withfrom_cif_path,from_cif_str,from_data_path, andfrom_scratchclassmethods.ExperimentFactoryinheritsFactoryBaseand uses@registeron all four concrete experiment classes;_resolve_classlooks up the registered class viadefault_tag()+_supported_map().StructureFactoryis a plain class withoutFactoryBaseinheritance (only one structure type exists today).
Tags are the user-facing identifiers for selecting types. They must be:
- Consistent β use the same abbreviations everywhere.
- Hyphen-separated β all lowercase, words joined by hyphens.
- Semantically ordered β from general to specific.
- Unique within a factory β but may overlap across factories.
| Concept | Abbreviation | Never use |
|---|---|---|
| Powder | pd |
powder |
| Single crystal | sc |
single-crystal |
| Constant wavelength | cwl |
cw, constant-wavelength |
| Time-of-flight | tof |
time-of-flight |
| Bragg (scattering) | bragg |
|
| Total (scattering) | total |
Background tags
| Tag | Class |
|---|---|
line-segment |
LineSegmentBackground |
chebyshev |
ChebyshevPolynomialBackground |
Peak tags
| Tag | Class |
|---|---|
pseudo-voigt |
CwlPseudoVoigt |
split-pseudo-voigt |
CwlSplitPseudoVoigt |
thompson-cox-hastings |
CwlThompsonCoxHastings |
tof-pseudo-voigt |
TofPseudoVoigt |
tof-pseudo-voigt-ikeda-carpenter |
TofPseudoVoigtIkedaCarpenter |
tof-pseudo-voigt-back-to-back |
TofPseudoVoigtBackToBack |
gaussian-damped-sinc |
TotalGaussianDampedSinc |
Instrument tags
| Tag | Class |
|---|---|
cwl-pd |
CwlPdInstrument |
cwl-sc |
CwlScInstrument |
tof-pd |
TofPdInstrument |
tof-sc |
TofScInstrument |
Data tags
| Tag | Class |
|---|---|
bragg-pd-cwl |
PdCwlData |
bragg-pd-tof |
PdTofData |
bragg-sc |
ReflnData |
total-pd |
TotalData |
Extinction tags
| Tag | Class |
|---|---|
shelx |
ShelxExtinction |
Linked-crystal tags
| Tag | Class |
|---|---|
default |
LinkedCrystal |
Experiment tags
| Tag | Class |
|---|---|
bragg-pd |
BraggPdExperiment |
total-pd |
TotalPdExperiment |
bragg-sc-cwl |
CwlScExperiment |
bragg-sc-tof |
TofScExperiment |
Calculator tags
| Tag | Class |
|---|---|
cryspy |
CryspyCalculator |
crysfml |
CrysfmlCalculator |
pdffit |
PdffitCalculator |
Minimizer tags
| Tag | Class |
|---|---|
lmfit |
LmfitMinimizer |
lmfit (leastsq) |
LmfitMinimizer (method=leastsq) |
lmfit (least_squares) |
LmfitMinimizer (method=least_squares) |
dfols |
DfolsMinimizer |
Note: minimizer variant tags (
lmfit (leastsq),lmfit (least_squares)) are planned but not yet re-implemented after theFactoryBasemigration. Seeissues_open.mdfor details.
If a concrete class is created by a factory, it gets
type_info,compatibility, andcalculator_support.If a
CategoryItemonly exists as a child row inside aCategoryCollection, it does NOT get these attributes β the collection does.
A LineSegment item (a single background control point) is never
selected, created, or queried by a factory. It is always instantiated
internally by its parent LineSegmentBackground collection. The
meaningful unit of selection is the collection, not the item. The user
picks "line-segment background" (the collection type), not individual
line-segment points.
| Class | Factory |
|---|---|
CwlPdInstrument |
InstrumentFactory |
CwlScInstrument |
InstrumentFactory |
TofPdInstrument |
InstrumentFactory |
TofScInstrument |
InstrumentFactory |
CwlPseudoVoigt |
PeakFactory |
CwlSplitPseudoVoigt |
PeakFactory |
CwlThompsonCoxHastings |
PeakFactory |
TofPseudoVoigt |
PeakFactory |
TofPseudoVoigtIkedaCarpenter |
PeakFactory |
TofPseudoVoigtBackToBack |
PeakFactory |
TotalGaussianDampedSinc |
PeakFactory |
ShelxExtinction |
ExtinctionFactory |
LinkedCrystal |
LinkedCrystalFactory |
Cell |
CellFactory |
SpaceGroup |
SpaceGroupFactory |
ExperimentType |
ExperimentTypeFactory |
FitMode |
FitModeFactory |
| Class | Factory |
|---|---|
LineSegmentBackground |
BackgroundFactory |
ChebyshevPolynomialBackground |
BackgroundFactory |
PdCwlData |
DataFactory |
PdTofData |
DataFactory |
TotalData |
DataFactory |
ReflnData |
DataFactory |
ExcludedRegions |
ExcludedRegionsFactory |
LinkedPhases |
LinkedPhasesFactory |
AtomSites |
AtomSitesFactory |
Aliases |
AliasesFactory |
Constraints |
ConstraintsFactory |
JointFitExperiments |
JointFitExperimentsFactory |
| Class | Parent collection |
|---|---|
LineSegment |
LineSegmentBackground |
PolynomialTerm |
ChebyshevPolynomialBackground |
AtomSite |
AtomSites |
PdCwlDataPoint |
PdCwlData |
PdTofDataPoint |
PdTofData |
TotalDataPoint |
TotalData |
Refln |
ReflnData |
LinkedPhase |
LinkedPhases |
ExcludedRegion |
ExcludedRegions |
Alias |
Aliases |
Constraint |
Constraints |
JointFitExperiment |
JointFitExperiments |
| Class | Factory | Notes |
|---|---|---|
CryspyCalculator |
CalculatorFactory |
No compatibility β limitations expressed on categories |
CrysfmlCalculator |
CalculatorFactory |
(same) |
PdffitCalculator |
CalculatorFactory |
(same) |
LmfitMinimizer |
MinimizerFactory |
type_info only |
DfolsMinimizer |
MinimizerFactory |
(same) |
BraggPdExperiment |
ExperimentFactory |
type_info + compatibility (no calculator_support) |
TotalPdExperiment |
ExperimentFactory |
(same) |
CwlScExperiment |
ExperimentFactory |
(same) |
TofScExperiment |
ExperimentFactory |
(same) |
The calculator performs the actual diffraction computation. It is
attached per-experiment on the ExperimentBase object. Each experiment
auto-resolves its calculator on first access based on the data
category's calculator_support metadata and
CalculatorFactory._default_rules. The CalculatorFactory filters its
registry by engine_imported (whether the third-party library is
available in the environment).
The experiment exposes the standard switchable-category API:
calculatorβ read-only property (lazy, auto-resolved on first access)calculator_typeβ getter + settershow_supported_calculator_types()β filtered by data category supportshow_current_calculator_type()
The minimiser drives the optimisation loop. MinimizerFactory creates
instances by tag (e.g. 'lmfit', 'dfols').
Fitter wraps a minimiser instance and orchestrates the fitting
workflow:
- Collect
free_parametersfrom structures + experiments. - Record start values.
- Build an objective function that calls the calculator.
- Delegate to
minimizer.fit(). - Sync results (values + uncertainties) back to parameters.
Analysis is bound to a Project and provides the high-level API:
- Minimiser selection:
current_minimizer,show_available_minimizers() - Fit mode:
fit_mode(CategoryItemwith amodedescriptor validated byFitModeEnum);'single'fits each experiment independently,'joint'fits all simultaneously with weights fromjoint_fit_experiments. - Joint-fit weights:
joint_fit_experiments(CategoryCollectionof per-experiment weight entries); sibling offit_mode, not a child. - Parameter tables:
show_all_params(),show_fittable_params(),show_free_params(),how_to_access_parameters() - Fitting:
fit(),show_fit_results() - Aliases and constraints (switchable categories with
aliases_type,constraints_type,fit_mode_type,joint_fit_experiments_type)
Project is the single entry point for the user:
import easydiffraction as ed
project = ed.Project(name='my_project')It owns and coordinates all components:
| Property | Type | Description |
|---|---|---|
project.info |
ProjectInfo |
Metadata: name, title, description, path |
project.structures |
Structures |
Collection of structure datablocks |
project.experiments |
Experiments |
Collection of experiment datablocks |
project.analysis |
Analysis |
Calculator, minimiser, fitting |
project.summary |
Summary |
Report generation |
project.plotter |
Plotter |
Visualisation |
project.verbosity |
str |
Console output level (full/short/silent) |
Parameter.value set
β AttributeSpec validation (type + value)
β _need_categories_update = True (on parent DatablockItem)
Plot / CIF export / fit objective evaluation
β _update_categories()
β categories sorted by _update_priority
β each category._update()
β background: interpolate/evaluate β write to data
β calculator: compute pattern β write to data
β _need_categories_update = False
Projects are saved as a directory of CIF files:
project_dir/
βββ project.cif # ProjectInfo
βββ analysis.cif # Analysis settings
βββ summary.cif # Summary report
βββ structures/
β βββ lbco.cif # One file per structure
βββ experiments/
βββ hrpt.cif # One file per experimentProject.verbosity controls how much console output operations produce.
It is backed by VerbosityEnum (in utils/enums.py) and accepts three
values:
| Level | Enum member | Behaviour |
|---|---|---|
full |
VerbosityEnum.FULL |
Multi-line output with headers, tables, and detail |
short |
VerbosityEnum.SHORT |
One-line status message per action |
silent |
VerbosityEnum.SILENT |
No console output |
The default is 'full'.
project.verbosity = 'short'Resolution order: methods that produce console output (e.g.
analysis.fit(), experiments.add_from_data_path()) accept an optional
verbosity keyword argument. When the argument is None (the default),
the method reads project.verbosity. When a string is passed, it
overrides the project-level setting for that single call.
# Use project-level default for all operations
project.verbosity = 'short'
project.analysis.fit() # β short mode
# Override for a single call
project.analysis.fit(verbosity='silent') # β silent, project stays shortOutput styles per level:
- Data loading β
full: paragraph header + detail line;short:β Data loaded: Experiment π¬ 'name'. N points.;silent: nothing. - Fitting β
full: per-iteration progress table with improvement percentages;short: one-row-per-experiment summary table;silent: nothing.
All examples below are drawn from the actual tutorials (tutorials/).
Notebook workflow: Jupyter notebooks (
*.ipynb) indocs/docs/tutorials/are generated artifacts. Edit only the corresponding*.pyscript, then runpixi run notebook-convertfollowed bypixi run notebook-prepareto regenerate the notebook. Never edit*.ipynbfiles by hand.
import easydiffraction as ed
project = ed.Project(name='lbco_hrpt')
project.info.title = 'La0.5Ba0.5CoO3 at HRPT@PSI'
project.save_as(dir_path='lbco_hrpt', temporary=True)# Create a structure datablock
project.structures.create(name='lbco')
# Set space group and unit cell
project.structures['lbco'].space_group.name_h_m = 'P m -3 m'
project.structures['lbco'].cell.length_a = 3.88
# Add atom sites
project.structures['lbco'].atom_sites.create(
label='La',
type_symbol='La',
fract_x=0,
fract_y=0,
fract_z=0,
wyckoff_letter='a',
b_iso=0.5,
occupancy=0.5,
)
# Show as CIF
project.structures['lbco'].show_as_cif()# Download data and create experiment from a data file
data_path = ed.download_data(id=3, destination='data')
project.experiments.add_from_data_path(
name='hrpt',
data_path=data_path,
sample_form='powder',
beam_mode='constant wavelength',
radiation_probe='neutron',
)
# Set instrument parameters
project.experiments['hrpt'].instrument.setup_wavelength = 1.494
project.experiments['hrpt'].instrument.calib_twotheta_offset = 0.6
# Browse and select peak profile type
project.experiments['hrpt'].show_supported_peak_profile_types()
project.experiments['hrpt'].peak_profile_type = 'pseudo-voigt'
# Set peak profile parameters
project.experiments['hrpt'].peak.broad_gauss_u = 0.1
project.experiments['hrpt'].peak.broad_gauss_v = -0.1
# Browse and select background type
project.experiments['hrpt'].show_supported_background_types()
project.experiments['hrpt'].background_type = 'line-segment'
# Add background points
project.experiments['hrpt'].background.create(id='10', x=10, y=170)
project.experiments['hrpt'].background.create(id='50', x=50, y=170)
# Link structure to experiment
project.experiments['hrpt'].linked_phases.create(id='lbco', scale=10.0)# Calculator is auto-resolved per experiment; override if needed
project.experiments['hrpt'].show_supported_calculator_types()
project.experiments['hrpt'].calculator_type = 'cryspy'
project.analysis.current_minimizer = 'lmfit'
# Plot before fitting
project.plot_meas_vs_calc(expt_name='hrpt', show_residual=True)
# Select free parameters
project.structures['lbco'].cell.length_a.free = True
project.experiments['hrpt'].linked_phases['lbco'].scale.free = True
project.experiments['hrpt'].instrument.calib_twotheta_offset.free = True
project.experiments['hrpt'].background['10'].y.free = True
# Inspect free parameters
project.analysis.show_free_params()
# Fit and show results
project.analysis.fit()
project.analysis.show_fit_results()
# Plot after fitting
project.plot_meas_vs_calc(expt_name='hrpt', show_residual=True)
# Save
project.save()expt = ExperimentFactory.from_data_path(
name='sepd',
data_path=data_path,
beam_mode='time-of-flight',
)
expt.instrument.calib_d_to_tof_offset = 0.0
expt.instrument.calib_d_to_tof_linear = 7476.91
expt.peak_profile_type = 'pseudo-voigt * ikeda-carpenter'
expt.peak.broad_gauss_sigma_0 = 3.0project.experiments.add_from_data_path(
name='xray_pdf',
data_path=data_path,
sample_form='powder',
beam_mode='constant wavelength',
radiation_probe='xray',
scattering_type='total',
)
project.experiments['xray_pdf'].peak_profile_type = 'gaussian-damped-sinc'
# Calculator is auto-resolved to 'pdffit' for total scattering experiments- Follow CIF naming conventions where possible. Deviate for better API design when necessary, but keep the spirit of CIF names.
- Reuse the concept of datablocks and categories from CIF.
DatablockItem= one CIFdata_block,DatablockCollection= set of blocks.CategoryItem= one CIF category,CategoryCollection= CIF loop.
The experiment type (the four enum axes) can only be set at creation time. It cannot be changed afterwards. This avoids the complexity of maintaining different state transformations when switching between fundamentally different experiment configurations.
In contrast to experiment type, categories that have multiple implementations (peak profiles, backgrounds, instruments) can be switched at runtime by the user. The API pattern uses a type property on the experiment, not on the category itself:
# β
Correct β type property on the experiment
expt.background_type = 'chebyshev'
# β Not used β type property on the category
expt.background.type = 'chebyshev'This makes it clear that the entire category object is being replaced and simplifies maintenance.
Categories whose concrete implementation can be swapped at runtime (background, peak profile, etc.) are called switchable categories. Every category must be factory-based β even if only one implementation exists today. This ensures a uniform API, consistent discoverability, and makes adding a second implementation trivial.
| Facet | Naming pattern | Example |
|---|---|---|
| Current object | <category> property (read-only) |
expt.background, expt.peak |
| Active type tag | <category>_type property (getter + setter) |
expt.background_type, expt.peak_profile_type |
| Show supported | show_supported_<category>_types() |
expt.show_supported_background_types() |
| Show current | show_current_<category>_type() |
expt.show_current_peak_profile_type() |
The convention applies universally:
- Experiment:
calculator_type,background_type,peak_profile_type,extinction_type,linked_crystal_type,excluded_regions_type,linked_phases_type,instrument_type,data_type. - Structure:
cell_type,space_group_type,atom_sites_type. - Analysis:
aliases_type,constraints_type,fit_mode_type,joint_fit_experiments_type.
Design decisions:
- The experiment owns the
_typesetter because switching replaces the entire category object (self._background = BackgroundFactory.create(...)). - The experiment owns the
show_*methods because they are one-liners that delegate toFactory.show_supported(...)and can pass experiment-specific context (e.g.scattering_type,beam_modefor peak filtering). - Concrete category subclasses provide a public
show()method for displaying the current content (not on the baseCategoryItem/CategoryCollection).
The user can always discover what is supported for the current experiment:
expt.show_supported_peak_profile_types()
expt.show_supported_background_types()
expt.show_supported_calculator_types()
expt.show_supported_extinction_types()
expt.show_supported_linked_crystal_types()
expt.show_supported_excluded_regions_types()
expt.show_supported_linked_phases_types()
expt.show_supported_instrument_types()
expt.show_supported_data_types()
struct.show_supported_cell_types()
struct.show_supported_space_group_types()
struct.show_supported_atom_sites_types()
project.analysis.show_supported_aliases_types()
project.analysis.show_supported_constraints_types()
project.analysis.show_supported_fit_mode_types()
project.analysis.show_supported_joint_fit_experiments_types()
project.analysis.show_available_minimizers()Available calculators are filtered by engine_imported (whether the
library is installed) and by the experiment's data category
calculator_support metadata.
Every attribute, descriptor, or configuration option that accepts a
finite, closed set of values must be represented by a (str, Enum)
class. This applies to:
- Factory tags (Β§5.6) β e.g.
PeakProfileTypeEnum,CalculatorEnum. - Experiment-axis values β e.g.
SampleFormEnum,BeamModeEnum. - Category descriptors with enumerated choices β e.g. fit mode
(
FitModeEnum.SINGLE,FitModeEnum.JOINT).
The enum serves as the single source of truth for valid values, their user-facing string representations, and their descriptions. Benefits:
- Autocomplete and typo safety β IDEs list valid members; misspellings are caught at assignment time.
- Greppable β searching for
FitModeEnum.JOINTfinds every code path that handles joint fitting. - Type-safe dispatch β
if mode == FitModeEnum.JOINT:is checked by type checkers;if mode == 'joint':is not. - Consistent validation β use
MembershipValidatorwith the enum members instead ofRegexValidatorwith hand-written patterns.
Rule: internal code must compare against enum members, never raw
strings. User-facing setters accept either the enum member or its string
value (because str(EnumMember) == EnumMember.value for (str, Enum)),
but internal dispatch always uses the enum:
# β
Correct β compare with enum
if self._fit_mode.mode.value == FitModeEnum.JOINT:
# β Wrong β compare with raw string
if self._fit_mode.mode.value == 'joint':Following CIF conventions, categories are flat siblings within their owner (datablock or analysis object). A category must never be a child of another category of a different type. Categories can reference each other via IDs, but the ownership hierarchy is always:
Owner (DatablockItem / Analysis)
βββ CategoryA (CategoryItem or CategoryCollection)
βββ CategoryB (CategoryItem or CategoryCollection)
βββ CategoryC (CategoryItem or CategoryCollection)
Never:
Owner
βββ CategoryA
βββ CategoryB β WRONG: CategoryB is a child of CategoryA
Example β fit_mode and joint_fit_experiments: fit_mode is a
CategoryItem holding the active strategy ('single' or 'joint').
joint_fit_experiments is a separate CategoryCollection holding
per-experiment weights. Both are direct children of Analysis, not
nested:
# β
Correct β sibling categories on Analysis
project.analysis.fit_mode.mode = 'joint'
project.analysis.joint_fit_experiments['npd'].weight = 0.7
# β Wrong β joint_fit_experiments as a child of fit_mode
project.analysis.fit_mode.joint_fit_experiments['npd'].weight = 0.7In CIF output, sibling categories appear as independent blocks:
_analysis.fit_mode joint
loop_
_joint_fit_experiment.id
_joint_fit_experiment.weight
npd 0.7
xrd 0.3
Every public property backed by a private Parameter,
NumericDescriptor, or StringDescriptor attribute must follow the
template below. The description field on the descriptor is the
single source of truth; docstrings and type hints are mechanically
derived from it.
Definitions:
| Symbol | Meaning |
|---|---|
{desc} |
description string without trailing period |
{units} |
units string; omit the ({units}) parenthetical when absent/empty |
{Type} |
Descriptor class name: Parameter, NumericDescriptor, or StringDescriptor |
{ann} |
Setter value annotation: float for numeric descriptors, str for string descriptors |
Template β writable property:
@property
def length_a(self) -> Parameter:
"""Length of the a axis of the unit cell (Γ
).
Reading this property returns the underlying ``Parameter``
object. Assigning to it updates the parameter value.
"""
return self._length_a
@length_a.setter
def length_a(self, value: float) -> None:
self._length_a.value = valueTemplate β read-only property:
@property
def length_a(self) -> Parameter:
"""Length of the a axis of the unit cell (Γ
).
Reading this property returns the underlying ``Parameter``
object.
"""
return self._length_aQuick-reference table:
| Element | Text |
|---|---|
| Getter summary line | """{desc} ({units}). (or """{desc}. when unitless) |
| Getter body (writable) | Reading this property returns the underlying ``{Type}`` object. Assigning to it updates the parameter value. |
| Getter body (readonly) | Reading this property returns the underlying ``{Type}`` object. |
| Setter docstring | (none β not rendered by griffe / MkDocs) |
| Getter annotation | -> {Type} |
| Setter annotation | value: {ann} and -> None |
Notes:
- Getter docstrings have no
Args:orReturns:sections. - Setters have no docstring.
- Avoid markdown emphasis (
*a*) in docstrings; use plain text to stay in sync with thedescriptionfield. - The CI tool
pixi run param-consistency-checkvalidates compliance;pixi run param-consistency-fixauto-fixes violations.
- Open:
issues_open.mdβ prioritised backlog. - Closed:
issues_closed.mdβ resolved items for reference.
When a resolution affects the architecture described above, the relevant sections of this document are updated accordingly.